audio-processing, laravel-support, skript, readme
This commit is contained in:
parent
99517f483a
commit
2e2490c1bc
@ -21,10 +21,15 @@ VIDEO_GID=983
|
|||||||
|
|
||||||
# GPU-Auswahl (0 = erste ROCm-GPU; iGPU ausblenden)
|
# GPU-Auswahl (0 = erste ROCm-GPU; iGPU ausblenden)
|
||||||
HIP_VISIBLE_DEVICES=0
|
HIP_VISIBLE_DEVICES=0
|
||||||
|
# GPU-Auswahl für Vulkan (sd-server). Vulkan zählt die iGPU mit – Reihenfolge steht
|
||||||
|
# beim Laden eines Bildmodells im Log: docker compose logs llama-swap | grep ggml_vulkan
|
||||||
|
VK_VISIBLE_DEVICES=1
|
||||||
|
|
||||||
# Optional: Hugging-Face-Token für gated Modelle / höhere Download-Limits
|
# Optional: Hugging-Face-Token für gated Modelle / höhere Download-Limits
|
||||||
HF_TOKEN=
|
HF_TOKEN=
|
||||||
|
|
||||||
# Image-Versionen (gepinnt)
|
# Image-Versionen (gepinnt)
|
||||||
LLAMA_SWAP_TAG=v265-rocm-b11610
|
LLAMA_SWAP_TAG=v265-rocm-b11610
|
||||||
|
# Quelle für whisper-server/sd-server (gleiche llama-swap-Version wie oben wählen)
|
||||||
|
UNIFIED_TAG=unified-vulkan-265
|
||||||
CADDY_TAG=2.10.2-alpine
|
CADDY_TAG=2.10.2-alpine
|
||||||
|
|||||||
227
PLAN-multimodal.md
Normal file
227
PLAN-multimodal.md
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# Plan: Transkription + Bildgenerierung, Anbindung an Laravel (laravel/ai)
|
||||||
|
|
||||||
|
> **Stand der Umsetzung (2026-10-15) – Server fertig, Laravel-Teil dokumentiert:**
|
||||||
|
> - **Weder A noch B, sondern Variante C:** Vulkan schafft nur 54 % der ROCm-Prompt-Verarbeitung (Messwerte unten). Deshalb baut [`llama-swap/Dockerfile`](llama-swap/Dockerfile) auf dem ROCm-Image auf und kopiert `whisper-server` + `sd-server` aus `unified-vulkan-265` dazu. LLMs laufen weiter auf ROCm, Whisper und Bilder über Vulkan, und alles verwaltet ein llama-swap.
|
||||||
|
> - Vulkan-Gerät 0 ist die **iGPU**. Die RX 6950 XT ist Gerät 1 → `VK_VISIBLE_DEVICES=1`.
|
||||||
|
> - **Whisper läuft auf der GPU statt der CPU:** 0,46 s statt 3,3 s (CPU, 12 Threads, `-l de`) bzw. 9,3 s (CPU, 8 Threads, `-l auto`) für 6,6 s Sprache. Es braucht ~1,2 GB VRAM und kostet die Chat-Geschwindigkeit nachweislich nichts (852 t/s mit und ohne).
|
||||||
|
> - ffmpeg ist im ROCm-Image schon enthalten, `--convert` funktioniert: WAV, WebM/Opus und M4A/AAC werden fehlerfrei transkribiert.
|
||||||
|
> - Bilder: Z-Image-Turbo Q8, VAE von `Comfy-Org/z_image_turbo` (nicht gated). Die Dauer hängt an der GPU-Rechenleistung (~4 s/Schritt bei 1024²). `--offload-to-cpu` / `--backend te=cpu` ändern daran nichts, `--offload-to-cpu` bleibt (spart VRAM).
|
||||||
|
> - OpenAI-Format getestet: Alias `gpt-image-1`, `size: 1536x1024`, `quality` (wird ignoriert), ohne `response_format` → `b64_json`.
|
||||||
|
> - `scripts/start.sh`: baut das Image, lädt Whisper vor, `--pull` aktualisiert die Basis-Images und baut neu.
|
||||||
|
> - Phase 5 (Laravel) ist als Anleitung in [`docs/laravel.md`](docs/laravel.md) umgesetzt. In einer echten Laravel-App ist sie **noch nicht getestet** (offen: wo die App läuft).
|
||||||
|
> - Nebenbefund, älter als dieser Plan: Der Startup-Preload von llama-swap meldet für `qwen2.5-coder-1.5b`/`nomic-embed` „status 404“ (llama-server ohne Web-UI antwortet auf `/` mit 404). Die Modelle laden trotzdem.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Eine Laravel-Anwendung soll über das **Laravel AI SDK** (`laravel/ai`) diesen Server nutzen:
|
||||||
|
1. **Agent** (Chat, Tools, Streaming, Embeddings). Das geht schon heute ohne Server-Änderung.
|
||||||
|
2. **Spracheingabe**: Aufnahmetaste im Browser → Transkription (Whisper) → Text als Prompt an den Agenten.
|
||||||
|
3. **Bildgenerierung**, auch als Tool des Agenten.
|
||||||
|
|
||||||
|
Was das SDK laut Doku (https://laravel.com/docs/ai-sdk) kann:
|
||||||
|
|
||||||
|
| Funktion | Treiber `openai-compatible` | Bemerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Text, Streaming, Tools, Structured Output | ✅ | |
|
||||||
|
| Embeddings | ✅ | Standardmodell in der Config nötig |
|
||||||
|
| Transkription (STT) | ✅ | Standardmodell in der Config nötig, keine Sprecher-Trennung (Diarization) |
|
||||||
|
| Bilder | ❌ | Umweg: Treiber `openai` mit eigener `url` oder ein eigener HTTP-Aufruf |
|
||||||
|
| TTS | ❌ | nicht Teil dieses Plans |
|
||||||
|
|
||||||
|
## Kernproblem: Image-Wahl (ROCm vs. Vulkan)
|
||||||
|
Das aktuelle Image `llama-swap:vXXX-rocm-bYYYY` enthält **nur llama-server**.
|
||||||
|
`whisper-server` und `sd-server` gibt es nur im **unified**-Image, und das für AMD nur als
|
||||||
|
**`unified-vulkan`** (versionierte Tags: `unified-vulkan-NNN`). Ein ROCm-unified-Image gibt es nicht.
|
||||||
|
|
||||||
|
| | A: alles auf `unified-vulkan` (**bevorzugt**) | B: ROCm bleibt, Whisper separat |
|
||||||
|
|---|---|---|
|
||||||
|
| Container | 1 × llama-swap | llama-swap (ROCm) + whisper-Container (CPU) |
|
||||||
|
| VRAM-Koordination Chat ↔ Bildmodell | llama-swap übernimmt das (Gruppe `chat`) | keine. Bildmodell und Chat-Modell würden sich um VRAM streiten, **Bilder fallen in B weg** |
|
||||||
|
| Routing | alles über llama-swap | Caddy leitet `/v1/audio/*` am llama-swap vorbei |
|
||||||
|
| Risiko | Vulkan ist evtl. langsamer als ROCm, deshalb Benchmark | mehr Teile, keine Bilder |
|
||||||
|
|
||||||
|
**Entscheidung über Benchmark (Phase 1):** Kommt Vulkan bei Token-Generierung **und** Prompt-Verarbeitung
|
||||||
|
auf ≥ ~85 % von ROCm, dann Variante A. Sonst Variante B, und die Bilder bleiben vorerst weg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Benchmark Vulkan vs. ROCm (~1 h)
|
||||||
|
1. Den Tag ermitteln: https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap → neuestes `unified-vulkan-NNN`.
|
||||||
|
2. Messen, während der laufende Stack aus ist (`docker compose stop`), mit denselben Modelldateien aus `./models`:
|
||||||
|
```bash
|
||||||
|
docker run --rm --device /dev/dri --group-add 987 --group-add 983 \
|
||||||
|
-e GGML_VK_VISIBLE_DEVICES=0 -v ./models:/models \
|
||||||
|
--entrypoint llama-bench ghcr.io/mostlygeek/llama-swap:unified-vulkan-NNN \
|
||||||
|
-m /models/<Pfad zur Qwen3.5-35B-A3B-Q4_K_M.gguf> -p 2048 -n 128 -fa 1
|
||||||
|
```
|
||||||
|
Dasselbe mit dem ROCm-Image (`/dev/kfd` dazu, `HIP_VISIBLE_DEVICES=0`). Den GGUF-Pfad unter
|
||||||
|
`models/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/*/` suchen.
|
||||||
|
- Ob `llama-bench` in beiden Images vorhanden ist, prüfen. Falls nicht: `llama-server` starten und die t/s aus dem Log einer Test-Anfrage nehmen.
|
||||||
|
- Wichtig: Für den 35B-MoE muss die Auslagerung in den RAM gleich sein. Notfalls mit `--n-cpu-moe` vergleichbar festlegen.
|
||||||
|
3. `GGML_VK_VISIBLE_DEVICES=0`: Der 7900X3D hat eine iGPU, die Vulkan sonst als zweites Gerät sieht.
|
||||||
|
Mit `vulkaninfo --summary` bzw. dem Startlog prüfen, dass Gerät 0 die RX 6950 XT ist.
|
||||||
|
4. Die Ergebnisse unten unter „Messwerte“ eintragen und A oder B entscheiden.
|
||||||
|
|
||||||
|
## Phase 2 (A): Stack auf `unified-vulkan` umstellen
|
||||||
|
- `.env` / `.env.example`: `LLAMA_SWAP_TAG=unified-vulkan-NNN`. Den alten ROCm-Tag als Kommentar behalten (Rollback).
|
||||||
|
- `docker-compose.yml` (llama-swap):
|
||||||
|
- `devices`: nur noch `/dev/dri` nötig (`/dev/kfd` schadet nicht, kann aber weg).
|
||||||
|
- `environment`: `GGML_VK_VISIBLE_DEVICES: "${VK_VISIBLE_DEVICES:-0}"` statt bzw. zusätzlich zu `HIP_VISIBLE_DEVICES`.
|
||||||
|
- Prüfen, ob das Image mit `user: 1000:1000` läuft. Es gibt auch `-rootless`-Varianten.
|
||||||
|
- Prüfen, ob Binärpfade (`/app/llama-server` vs. `llama-server` im PATH) zum `server`-Makro passen.
|
||||||
|
- `llama-swap/config.yaml`: Bestehende Modelle unverändert lassen. Prüfen, ob `--fit` / `--fit-target` unter Vulkan genauso greift (VRAM mit `rocm-smi` beobachten).
|
||||||
|
- Gegenprobe: opencode + Continue (Chat, Autocomplete, `@codebase`) funktionieren wie vorher.
|
||||||
|
- README: Tabelle „Auf einen anderen PC übertragen“ anpassen (unified-vulkan ist jetzt Standard).
|
||||||
|
|
||||||
|
## Phase 3: Whisper (Transkription)
|
||||||
|
1. Modell herunterladen (whisper-server kann kein `-hf`):
|
||||||
|
```bash
|
||||||
|
mkdir -p models/whisper
|
||||||
|
curl -L -o models/whisper/ggml-large-v3-turbo-q8_0.bin \
|
||||||
|
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin
|
||||||
|
```
|
||||||
|
2. Eintrag in `llama-swap/config.yaml`, **auf der CPU** und in `always-on`, damit er nie das Chat-Modell verdrängt:
|
||||||
|
```yaml
|
||||||
|
whisper:
|
||||||
|
name: Whisper large-v3-turbo (CPU)
|
||||||
|
checkEndpoint: / # prüfen: hat whisper-server /health?
|
||||||
|
cmd: >-
|
||||||
|
whisper-server --host 127.0.0.1 --port ${PORT}
|
||||||
|
-m /models/whisper/ggml-large-v3-turbo-q8_0.bin
|
||||||
|
--inference-path /v1/audio/transcriptions
|
||||||
|
--convert --tmp-dir /tmp
|
||||||
|
-ng -t 8 -l auto
|
||||||
|
ttl: 0
|
||||||
|
```
|
||||||
|
- Die Flags mit `docker compose exec llama-swap whisper-server --help` prüfen: `--inference-path`, `--convert` (braucht **ffmpeg** im Image, mit `which ffmpeg` prüfen), `-ng`/`--no-gpu`, `--tmp-dir`.
|
||||||
|
- Ohne ffmpeg: `--convert` weglassen, dann wandelt Laravel vorher in 16-kHz-Mono-WAV um (siehe Phase 5).
|
||||||
|
- `whisper` zu `routing…groups.always-on.members` und zu `hooks.on_startup.preload` hinzufügen.
|
||||||
|
3. `scripts/start.sh`: Beim Preload einen Whisper-Request schicken. Dafür eine kurze Test-WAV unter `scripts/` ablegen, z. B. 1 s Stille mit `ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 scripts/silence.wav`.
|
||||||
|
4. Test:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:8800/v1/audio/transcriptions \
|
||||||
|
-F model=whisper -F file=@test.webm -F response_format=json
|
||||||
|
```
|
||||||
|
Mit einer echten deutschen Sprachaufnahme testen und die Dauer bei ~10 s Audio notieren (Ziel < 3 s).
|
||||||
|
5. Caddy: keine Änderung nötig. Es gibt standardmäßig kein Upload-Limit, und der `read_timeout` von 30 min reicht.
|
||||||
|
|
||||||
|
## Phase 4: Bildgenerierung (nur Variante A)
|
||||||
|
1. Modell: **Z-Image-Turbo** (Vorlage aus `docker/config.example.yaml` von llama-swap, 8 Steps, schnell).
|
||||||
|
Benötigt drei Dateien in `models/sd/`: `z_image_turbo-Q8_0.gguf`, `ae.safetensors` (VAE), `qwen3-4b-instruct-2507-q8_0.gguf` (Text-Encoder).
|
||||||
|
Die Hugging-Face-Quellen beim Umsetzen heraussuchen. Alternative: FLUX.1-schnell (GGUF).
|
||||||
|
2. Eintrag in der **`chat`-Gruppe**, damit llama-swap das Chat-Modell entlädt, solange ein Bild generiert wird:
|
||||||
|
```yaml
|
||||||
|
z-image:
|
||||||
|
name: Z-Image Turbo (Bilder)
|
||||||
|
checkEndpoint: /
|
||||||
|
cmd: |
|
||||||
|
sd-server --listen-ip 127.0.0.1 --listen-port ${PORT}
|
||||||
|
--diffusion-fa
|
||||||
|
--diffusion-model /models/sd/z_image_turbo-Q8_0.gguf
|
||||||
|
--vae /models/sd/ae.safetensors
|
||||||
|
--llm /models/sd/qwen3-4b-instruct-2507-q8_0.gguf
|
||||||
|
--offload-to-cpu --cfg-scale 1.0 --steps 8
|
||||||
|
--height 1024 --width 1024
|
||||||
|
aliases: [gpt-image-1, dall-e-3]
|
||||||
|
ttl: 600
|
||||||
|
```
|
||||||
|
- Die Flags mit `sd-server --help` prüfen, besonders den Namen von `--listen-ip`.
|
||||||
|
- Die `aliases` sind wichtig: Der OpenAI-Treiber von Laravel schickt seinen eigenen Modellnamen.
|
||||||
|
3. Test: `curl …/v1/images/generations -d '{"model":"z-image","prompt":"a red bicycle","response_format":"b64_json"}'`.
|
||||||
|
Dabei messen: Dauer inkl. Modellwechsel, VRAM-Spitze, ob Autocomplete (Continue) weiterläuft.
|
||||||
|
4. **Hinweis für den Alltag:** Jede Bildgenerierung wirft das Chat-Modell raus (Neuladen dauert einige zehn Sekunden).
|
||||||
|
Für einen Agenten, der Bilder als Tool erzeugt, heißt das: Chat → Bild → Chat bedeutet zwei Modellwechsel.
|
||||||
|
|
||||||
|
## Phase 5: Laravel-Anwendung
|
||||||
|
### 5.1 Config (`config/ai.php` + `.env`)
|
||||||
|
```php
|
||||||
|
'providers' => [
|
||||||
|
// Text, Agent, Embeddings, Transkription
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'openai-compatible',
|
||||||
|
'url' => env('LOCAL_AI_URL'), // http://<server>:8800/v1
|
||||||
|
'key' => env('LOCAL_AI_API_KEY'),
|
||||||
|
'models' => [
|
||||||
|
'text' => ['default' => 'qwen3.5-35b-a3b'],
|
||||||
|
'embeddings' => ['default' => 'nomic-embed', 'dimensions' => 768],
|
||||||
|
'transcription' => ['default' => 'whisper'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// nur für Bilder: OpenAI-Treiber auf den eigenen Server umgebogen
|
||||||
|
'local-images' => [
|
||||||
|
'driver' => 'openai',
|
||||||
|
'key' => env('LOCAL_AI_API_KEY'),
|
||||||
|
'url' => env('LOCAL_AI_URL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
- Den Env-Namen für die URL-Überschreibung je nach SDK-Version prüfen (Doku 13.x: `url`/`OPENAI_URL`, 12.x: `OPENAI_BASE_URL`).
|
||||||
|
- Den Schlüssel prüfen, mit dem `local` zum Standard-Provider für Transkription wird (`Transcription` hat in der Doku keinen `provider:`-Parameter).
|
||||||
|
- Netz: Der Laravel-Server muss in `ALLOWED_NETS` liegen, im LAN oder als NetBird-Peer.
|
||||||
|
- Timeouts großzügig setzen (`timeout: 300` beim Prompt), Agent-Läufe mit Tools als Queue-Job.
|
||||||
|
|
||||||
|
### 5.2 Agent
|
||||||
|
- `php artisan make:agent Assistant`: Anweisungen, Tools.
|
||||||
|
- Smoke-Test: `(new Assistant)->prompt('Hallo', provider: 'local')`, danach ein Tool-Call-Test.
|
||||||
|
|
||||||
|
### 5.3 Spracheingabe (Aufnahmetaste)
|
||||||
|
- **Frontend:** `getUserMedia` + `MediaRecorder` (gedrückt halten = aufnehmen). Bevorzugt `audio/webm;codecs=opus`, Safari liefert `audio/mp4`.
|
||||||
|
Den Blob per `FormData` an `POST /voice` schicken, die Antwort als Stream lesen.
|
||||||
|
- **HTTPS ist Pflicht.** Das Mikrofon geht nur in einem sicheren Kontext. Die Laravel-App braucht deshalb HTTPS, lokal geht auch `localhost`.
|
||||||
|
- **Backend:**
|
||||||
|
```php
|
||||||
|
Route::post('/voice/transcribe', fn (Request $r) =>
|
||||||
|
['text' => (string) Transcription::fromUpload($r->file('audio'))->generate()]);
|
||||||
|
Route::post('/voice/ask', fn (Request $r) =>
|
||||||
|
(new Assistant)->stream($r->string('text')));
|
||||||
|
```
|
||||||
|
Zwei Schritte, damit der User den erkannten Text **vor** dem Absenden sieht und korrigieren kann.
|
||||||
|
Das ist wichtig bei Agenten, deren Tools etwas ausführen.
|
||||||
|
- Wenn `whisper-server` kein `--convert` kann: In Laravel vorher umwandeln mit
|
||||||
|
`ffmpeg -i in.webm -ar 16000 -ac 1 out.wav` (Symfony Process), dann `Transcription::fromPath(...)`.
|
||||||
|
- Validierung: Upload-Limit (z. B. 20 MB), Mindestlänge, leere Transkripte abfangen.
|
||||||
|
|
||||||
|
### 5.4 Bilder
|
||||||
|
- Direkt: `Image::of('...')->generate(provider: 'local-images')` (die genaue API in der Doku nachsehen).
|
||||||
|
Falls der OpenAI-Treiber Parameter schickt, die `sd-server` ablehnt: Fallback auf `Http::withToken(...)->post(.../images/generations)`
|
||||||
|
und `b64_json` selbst in den Storage schreiben.
|
||||||
|
- Als Agent-Tool: `GenerateImage`-Tool, das eins von beidem aufruft und eine URL bzw. einen Storage-Pfad zurückgibt.
|
||||||
|
|
||||||
|
## Phase 6: Doku im Repo
|
||||||
|
- `docs/laravel.md`: Config, Netz/Key, Spracheingabe, Bilder, Stolperfallen (HTTPS, Formate, Modellwechsel).
|
||||||
|
- README: Modelltabelle um `whisper` und `z-image` ergänzen, Images und Rollback beschreiben.
|
||||||
|
- `PLAN.md`-Stil: Abweichungen oben in diesem Plan unter „Stand der Umsetzung“ festhalten.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
1. opencode + Continue funktionieren unverändert (nach dem Image-Wechsel!), die t/s liegen im erwarteten Bereich.
|
||||||
|
2. `GET /v1/models` listet `whisper` und `z-image`.
|
||||||
|
3. Transkription per curl (deutsch, webm und wav) liefert korrekten Text. Im Log wird dabei **kein** Chat-Modell entladen.
|
||||||
|
4. Bild per curl: PNG kommt zurück, danach lädt das Chat-Modell beim nächsten Request wieder.
|
||||||
|
5. Laravel: Agent-Prompt mit Tool-Call, Spracheingabe Ende-zu-Ende im Browser (Chrome + Firefox, wenn möglich Safari), Bild über das Agent-Tool.
|
||||||
|
6. Nach einem Reboot ist Whisper vorgeladen (`/running`).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
`LLAMA_SWAP_TAG` in `.env` zurück auf `v265-rocm-b11610`, `/dev/kfd` + `HIP_VISIBLE_DEVICES` wieder rein,
|
||||||
|
die Einträge `whisper`/`z-image` auskommentieren, `docker compose up -d`.
|
||||||
|
|
||||||
|
## Messwerte (in Phase 1 ausfüllen)
|
||||||
|
| Image | pp2048 t/s | tg128 t/s | Bemerkung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ROCm `v265-rocm-b11610` | ~1030 | ~49,8 | nur 35B geladen, `--fit-target 3072` |
|
||||||
|
| `unified-vulkan-265` | ~555 | ~43,2 | gleiche Bedingungen |
|
||||||
|
| ROCm im Betrieb (mit Autocomplete + Embeddings) | ~850 | ~47,5 | identisch mit und ohne Whisper auf der GPU |
|
||||||
|
|
||||||
|
Gemessen mit `llama-server` (Betriebs-Parameter), 2302 Prompt-Tokens, 256 generierte Tokens, `cache_prompt: false`.
|
||||||
|
|
||||||
|
| Bilder (Z-Image-Turbo Q8, 8 Schritte) | Dauer |
|
||||||
|
|---|---|
|
||||||
|
| 512×512 | ~11 s |
|
||||||
|
| 1024×1024 | ~40 s (Diffusion 32 s, VAE 6 s) |
|
||||||
|
| 1536×1024 | ~66 s |
|
||||||
|
| erster Request inkl. Laden | +4 s |
|
||||||
|
| Chat-Modell danach zurückladen | ~5 s |
|
||||||
|
| VRAM-Spitze beim Bild | 8,4 GB (mit Autocomplete + Whisper) |
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
- Wo läuft die Laravel-App (gleiches LAN, NetBird, öffentlicher Server)? Davon hängen `ALLOWED_NETS` und HTTPS ab.
|
||||||
|
- Bildmodell: Reichen Qualität und Geschwindigkeit von Z-Image-Turbo, oder lieber FLUX.1-schnell?
|
||||||
|
- Später ggf. TTS (die Antwort vorlesen): audio.cpp ist im unified-Image, aber das Laravel SDK kann TTS nicht über `openai-compatible`.
|
||||||
34
README.md
34
README.md
@ -1,4 +1,4 @@
|
|||||||
# LocalAI – zentraler LLM-Server für opencode & Continue
|
# LocalAI – zentraler LLM-Server für opencode, Continue & Laravel
|
||||||
|
|
||||||
Ein Docker-Compose-Stack, der auf **alegab-pc** (RX 6950 XT 16 GB, Ryzen 9 7900X3D, 62 GB RAM) eine **OpenAI-kompatible API** bereitstellt. Clients im Heim-LAN und über NetBird nutzen sie zum Programmieren, ganz ohne Cloud-KI.
|
Ein Docker-Compose-Stack, der auf **alegab-pc** (RX 6950 XT 16 GB, Ryzen 9 7900X3D, 62 GB RAM) eine **OpenAI-kompatible API** bereitstellt. Clients im Heim-LAN und über NetBird nutzen sie zum Programmieren, ganz ohne Cloud-KI.
|
||||||
|
|
||||||
@ -7,8 +7,10 @@ Client-PC (opencode / VS Code + Continue) ──LAN / NetBird──┐
|
|||||||
▼
|
▼
|
||||||
caddy (Host-Netz, :8800) IP-Filter + API-Key
|
caddy (Host-Netz, :8800) IP-Filter + API-Key
|
||||||
└─► llama-swap (nur 127.0.0.1:8080) lädt/wechselt Modelle
|
└─► llama-swap (nur 127.0.0.1:8080) lädt/wechselt Modelle
|
||||||
└─► llama-server (llama.cpp, ROCm) ─► GPU + RAM
|
├─► llama-server (llama.cpp, ROCm) Chat, Autocomplete, Embeddings
|
||||||
Modelle: ./models (GGUF, Download von Hugging Face beim ersten Aufruf)
|
├─► whisper-server (whisper.cpp, Vulkan) Transkription
|
||||||
|
└─► sd-server (stable-diffusion.cpp, Vulkan) Bilder
|
||||||
|
Modelle: ./models (LLMs per -hf beim ersten Aufruf, whisper/ und sd/ manuell)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Modell-ID | Zweck | Verhalten |
|
| Modell-ID | Zweck | Verhalten |
|
||||||
@ -18,6 +20,8 @@ Client-PC (opencode / VS Code + Continue) ──LAN / NetBird──┐
|
|||||||
| `qwen3.5-9b` | schnelles kleines Chat-Modell | 〃 |
|
| `qwen3.5-9b` | schnelles kleines Chat-Modell | 〃 |
|
||||||
| `qwen2.5-coder-1.5b` | Autocomplete (FIM) für Continue | dauerhaft geladen (GPU) |
|
| `qwen2.5-coder-1.5b` | Autocomplete (FIM) für Continue | dauerhaft geladen (GPU) |
|
||||||
| `nomic-embed` | Embeddings für Continue `@codebase` | dauerhaft geladen (CPU) |
|
| `nomic-embed` | Embeddings für Continue `@codebase` | dauerhaft geladen (CPU) |
|
||||||
|
| `whisper` | Transkription (`/v1/audio/transcriptions`), Alias `whisper-1` | dauerhaft geladen (GPU, ~1,2 GB) |
|
||||||
|
| `z-image` | Bilder (`/v1/images/generations`), Aliase `gpt-image-1`, `dall-e-3`, `dall-e-2` | verdrängt das Chat-Modell (Gruppe `chat`) |
|
||||||
|
|
||||||
## Server einrichten
|
## Server einrichten
|
||||||
|
|
||||||
@ -39,12 +43,27 @@ sudo ./scripts/firewall-ufw.sh # einmalig: Port 8800 für LAN + wt0 in ufw öf
|
|||||||
| `./scripts/start.sh --no-preload` | nur starten |
|
| `./scripts/start.sh --no-preload` | nur starten |
|
||||||
| `./scripts/start.sh --model glm-4.7-flash` | anderes Chat-Modell vorladen |
|
| `./scripts/start.sh --model glm-4.7-flash` | anderes Chat-Modell vorladen |
|
||||||
| `./scripts/start.sh --show-key` | API-Key im Klartext anzeigen (für Client-Setup) |
|
| `./scripts/start.sh --show-key` | API-Key im Klartext anzeigen (für Client-Setup) |
|
||||||
| `./scripts/start.sh --pull` | Images vorher aktualisieren |
|
| `./scripts/start.sh --pull` | Basis-Images aktualisieren und eigenes Image neu bauen |
|
||||||
|
|
||||||
**Nach einem Reboot** startet der Stack von selbst: Der Docker-Dienst ist aktiviert, und die Container haben `restart: unless-stopped`. Autocomplete und Embeddings lädt llama-swap per `hooks.on_startup.preload` ebenfalls von selbst, das Chat-Modell kommt beim ersten Request dazu.
|
**Nach einem Reboot** startet der Stack von selbst: Der Docker-Dienst ist aktiviert, und die Container haben `restart: unless-stopped`. Autocomplete und Embeddings lädt llama-swap per `hooks.on_startup.preload` ebenfalls von selbst, das Chat-Modell kommt beim ersten Request dazu.
|
||||||
|
|
||||||
Der erste Aufruf eines Modells lädt es von Hugging Face herunter (Hauptmodell ~22 GB). Danach läuft alles offline.
|
Der erste Aufruf eines Modells lädt es von Hugging Face herunter (Hauptmodell ~22 GB). Danach läuft alles offline.
|
||||||
|
|
||||||
|
Whisper und das Bildmodell lädt llama-swap **nicht** selbst herunter (~12 GB):
|
||||||
|
```bash
|
||||||
|
mkdir -p models/whisper models/sd
|
||||||
|
HF=https://huggingface.co
|
||||||
|
curl -L -o models/whisper/ggml-large-v3-turbo-q8_0.bin $HF/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin
|
||||||
|
curl -L -o models/sd/z_image_turbo-Q8_0.gguf $HF/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q8_0.gguf
|
||||||
|
curl -L -o models/sd/ae.safetensors $HF/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors
|
||||||
|
curl -L -o models/sd/Qwen3-4B-Instruct-2507-Q8_0.gguf $HF/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum ein eigenes Image?
|
||||||
|
`whisper-server` und `sd-server` gibt es nur im „unified“-Image von llama-swap, und das für AMD nur als Vulkan-Build. Unter Vulkan ist die Prompt-Verarbeitung von llama.cpp auf der RX 6950 XT aber nur halb so schnell wie unter ROCm (~555 statt ~1030 t/s). [`llama-swap/Dockerfile`](llama-swap/Dockerfile) nimmt deshalb das ROCm-Image als Basis und kopiert die beiden Server aus dem unified-Image dazu. `docker compose up -d --build` bzw. `scripts/start.sh` bauen es automatisch. Beide Tags stehen in `.env` (`LLAMA_SWAP_TAG`, `UNIFIED_TAG`). Bei einem Update beide auf dieselbe llama-swap-Version setzen und `./scripts/start.sh --pull` ausführen.
|
||||||
|
|
||||||
|
Vulkan zählt die iGPU des Ryzen mit. `VK_VISIBLE_DEVICES=1` in `.env` wählt die RX 6950 XT.
|
||||||
|
|
||||||
### Warum Caddy im Host-Netz?
|
### Warum Caddy im Host-Netz?
|
||||||
Ports, die Docker veröffentlicht, **umgehen ufw**. Deshalb ist llama-swap nur an `127.0.0.1` gebunden. Caddy läuft mit `network_mode: host`, dort greift ufw, und Caddy sieht die echte Quell-IP. Daraus ergeben sich zwei Schutzschichten:
|
Ports, die Docker veröffentlicht, **umgehen ufw**. Deshalb ist llama-swap nur an `127.0.0.1` gebunden. Caddy läuft mit `network_mode: host`, dort greift ufw, und Caddy sieht die echte Quell-IP. Daraus ergeben sich zwei Schutzschichten:
|
||||||
1. **ufw** lässt Port 8800 nur aus dem LAN und über `wt0` zu.
|
1. **ufw** lässt Port 8800 nur aus dem LAN und über `wt0` zu.
|
||||||
@ -56,6 +75,7 @@ Die API läuft über plain HTTP. Über NetBird ist das unkritisch (WireGuard ver
|
|||||||
|
|
||||||
- **opencode** (Terminal-Agent): [docs/opencode.md](docs/opencode.md)
|
- **opencode** (Terminal-Agent): [docs/opencode.md](docs/opencode.md)
|
||||||
- **Continue** (VS-Code-Plugin): [docs/continue.md](docs/continue.md)
|
- **Continue** (VS-Code-Plugin): [docs/continue.md](docs/continue.md)
|
||||||
|
- **Laravel** (laravel/ai – Agent, Spracheingabe, Bilder): [docs/laravel.md](docs/laravel.md)
|
||||||
|
|
||||||
Schnelltest von jedem Client:
|
Schnelltest von jedem Client:
|
||||||
```bash
|
```bash
|
||||||
@ -69,6 +89,7 @@ docker compose ps # Status
|
|||||||
docker compose logs -f llama-swap # Modell-Laden, Requests
|
docker compose logs -f llama-swap # Modell-Laden, Requests
|
||||||
docker compose logs -f caddy # Zugriffe
|
docker compose logs -f caddy # Zugriffe
|
||||||
docker compose restart # nach Änderungen an .env / Caddyfile
|
docker compose restart # nach Änderungen an .env / Caddyfile
|
||||||
|
docker compose up -d --build # nach Änderungen an llama-swap/Dockerfile oder den Image-Tags
|
||||||
```
|
```
|
||||||
- `llama-swap/config.yaml` wird **automatisch neu geladen**, ein Neustart ist nicht nötig.
|
- `llama-swap/config.yaml` wird **automatisch neu geladen**, ein Neustart ist nicht nötig.
|
||||||
- Web-Oberfläche von llama-swap (Modellstatus, Logs): `http://127.0.0.1:8080/ui`, nur lokal auf dem Server erreichbar.
|
- Web-Oberfläche von llama-swap (Modellstatus, Logs): `http://127.0.0.1:8080/ui`, nur lokal auf dem Server erreichbar.
|
||||||
@ -89,7 +110,7 @@ GPU-Layer und MoE-Auslagerung in den RAM verteilt llama.cpp automatisch (`--fit`
|
|||||||
|
|
||||||
1. Den Ordner kopieren. `models/` kann man mitnehmen, um den Download zu sparen.
|
1. Den Ordner kopieren. `models/` kann man mitnehmen, um den Download zu sparen.
|
||||||
2. `.env` anpassen: `ALLOWED_NETS` (LAN-Subnetz, NetBird-Netz), `RENDER_GID`/`VIDEO_GID`, `HOST_UID`/`HOST_GID`.
|
2. `.env` anpassen: `ALLOWED_NETS` (LAN-Subnetz, NetBird-Netz), `RENDER_GID`/`VIDEO_GID`, `HOST_UID`/`HOST_GID`.
|
||||||
3. Je nach GPU das Image in `.env` wählen (`LLAMA_SWAP_TAG`):
|
3. Je nach GPU das Image in `.env` wählen (`LLAMA_SWAP_TAG`). Das eigene Image ([`llama-swap/Dockerfile`](llama-swap/Dockerfile)) ist für ROCm gebaut. Bei NVIDIA oder Vulkan nimmt man einfacher direkt das passende `unified-…`-Image (enthält whisper-/sd-server schon) und ersetzt `build:` in `docker-compose.yml` durch `image: ghcr.io/mostlygeek/llama-swap:unified-…`. `VK_VISIBLE_DEVICES` an die Geräte-Reihenfolge anpassen.
|
||||||
|
|
||||||
| GPU | Tag-Variante |
|
| GPU | Tag-Variante |
|
||||||
|---|---|
|
|---|---|
|
||||||
@ -107,11 +128,14 @@ GPU-Layer und MoE-Auslagerung in den RAM verteilt llama.cpp automatisch (`--fit`
|
|||||||
| Datei | Inhalt |
|
| Datei | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docker-compose.yml` | Dienste llama-swap + caddy |
|
| `docker-compose.yml` | Dienste llama-swap + caddy |
|
||||||
|
| `llama-swap/Dockerfile` | ROCm-Image + whisper-server/sd-server |
|
||||||
| `.env` / `.env.example` | Secrets, Netze, Ports, GIDs, Image-Tags |
|
| `.env` / `.env.example` | Secrets, Netze, Ports, GIDs, Image-Tags |
|
||||||
| `llama-swap/config.yaml` | Modelle, Startparameter, Gruppen |
|
| `llama-swap/config.yaml` | Modelle, Startparameter, Gruppen |
|
||||||
| `caddy/Caddyfile` | IP-Filter, API-Key, Reverse Proxy |
|
| `caddy/Caddyfile` | IP-Filter, API-Key, Reverse Proxy |
|
||||||
| `client/opencode.json` | Vorlage für opencode-Clients |
|
| `client/opencode.json` | Vorlage für opencode-Clients |
|
||||||
| `client/continue/` | Vorlage für Continue-Clients |
|
| `client/continue/` | Vorlage für Continue-Clients |
|
||||||
|
| `docs/laravel.md` | Anbindung einer Laravel-App (laravel/ai) |
|
||||||
| `scripts/start.sh` | Start mit Prüfungen, Preload und Statusausgabe |
|
| `scripts/start.sh` | Start mit Prüfungen, Preload und Statusausgabe |
|
||||||
| `scripts/firewall-ufw.sh` | ufw-Freigaben |
|
| `scripts/firewall-ufw.sh` | ufw-Freigaben |
|
||||||
| `PLAN.md` | ursprünglicher Umsetzungsplan |
|
| `PLAN.md` | ursprünglicher Umsetzungsplan |
|
||||||
|
| `PLAN-multimodal.md` | Plan + Messwerte für Transkription/Bilder |
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
# Lokaler LLM-Server: llama-swap (llama.cpp/ROCm) hinter Caddy (IP-Filter + API-Key)
|
# Lokaler LLM-Server: llama-swap (llama.cpp/ROCm + whisper/sd-server) hinter Caddy (IP-Filter + API-Key)
|
||||||
# Start: docker compose up -d Logs: docker compose logs -f llama-swap
|
# Start: docker compose up -d --build Logs: docker compose logs -f llama-swap
|
||||||
name: localai
|
name: localai
|
||||||
|
|
||||||
services:
|
services:
|
||||||
llama-swap:
|
llama-swap:
|
||||||
image: ghcr.io/mostlygeek/llama-swap:${LLAMA_SWAP_TAG:-v265-rocm-b11610}
|
# ROCm-Image + whisper-server/sd-server aus dem unified-Image, siehe llama-swap/Dockerfile
|
||||||
|
build:
|
||||||
|
context: ./llama-swap
|
||||||
|
args:
|
||||||
|
LLAMA_SWAP_TAG: ${LLAMA_SWAP_TAG:-v265-rocm-b11610}
|
||||||
|
UNIFIED_TAG: ${UNIFIED_TAG:-unified-vulkan-265}
|
||||||
|
image: localai/llama-swap:${LLAMA_SWAP_TAG:-v265-rocm-b11610}-mm
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# nur lokal erreichbar – von außen ausschließlich über Caddy
|
# nur lokal erreichbar – von außen ausschließlich über Caddy
|
||||||
ports:
|
ports:
|
||||||
@ -23,6 +29,8 @@ services:
|
|||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
LLAMA_CACHE: /models
|
LLAMA_CACHE: /models
|
||||||
HIP_VISIBLE_DEVICES: "${HIP_VISIBLE_DEVICES:-0}"
|
HIP_VISIBLE_DEVICES: "${HIP_VISIBLE_DEVICES:-0}"
|
||||||
|
# Vulkan (sd-server) zählt die iGPU mit – hier ist die RX 6950 XT Gerät 1
|
||||||
|
GGML_VK_VISIBLE_DEVICES: "${VK_VISIBLE_DEVICES:-1}"
|
||||||
HF_TOKEN: "${HF_TOKEN:-}"
|
HF_TOKEN: "${HF_TOKEN:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./models:/models
|
- ./models:/models
|
||||||
|
|||||||
164
docs/laravel.md
Normal file
164
docs/laravel.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Laravel (laravel/ai) mit dem zentralen LocalAI-Server
|
||||||
|
|
||||||
|
Das [Laravel AI SDK](https://laravel.com/docs/ai-sdk) spricht den Server wie einen OpenAI-kompatiblen Anbieter an. Damit gehen Agenten mit Tools, Streaming, Embeddings, Transkription (Spracheingabe) und Bildgenerierung.
|
||||||
|
|
||||||
|
> Der Server-Teil ist getestet (Messwerte unten). Der Laravel-Code hier folgt der SDK-Doku, ist aber noch nicht in einer echten App gelaufen.
|
||||||
|
|
||||||
|
## Was der Server bietet
|
||||||
|
|
||||||
|
| Modell-ID (Aliase) | Endpoint | SDK-Funktion | Dauer (gemessen) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `qwen3.5-35b-a3b`, `glm-4.7-flash`, `qwen3.5-9b` | `/v1/chat/completions` | Agent, Text, Tools, Streaming | ~850 t/s Prompt, ~47 t/s Antwort |
|
||||||
|
| `nomic-embed` | `/v1/embeddings` | `Embeddings` (768 Dimensionen) | |
|
||||||
|
| `whisper` (`whisper-1`) | `/v1/audio/transcriptions` | `Transcription` | ~0,5 s für 7 s Sprache |
|
||||||
|
| `z-image` (`gpt-image-1`, `dall-e-3`, `dall-e-2`) | `/v1/images/generations` | `Image` | 512²: ~11 s, 1024²: ~40 s, 1536×1024: ~65 s |
|
||||||
|
|
||||||
|
**Bilder verdrängen das Chat-Modell.** Beide passen nicht gleichzeitig in die 16 GB VRAM. Der erste Bild-Request lädt das Bildmodell (+ einige Sekunden), der nächste Chat-Request lädt das Chat-Modell zurück (~5 s). Whisper, Autocomplete und Embeddings bleiben immer geladen.
|
||||||
|
|
||||||
|
## 1. Netz und Zugang
|
||||||
|
|
||||||
|
- Der Rechner, auf dem Laravel läuft, muss in `ALLOWED_NETS` (Server-`.env`) liegen: localhost, LAN `192.168.178.0/24` oder NetBird `100.73.0.0/16`. Ein öffentlicher Server braucht also einen NetBird-Peer.
|
||||||
|
- Der API-Key ist `LLM_API_KEY` aus der Server-`.env` (`./scripts/start.sh --show-key`).
|
||||||
|
|
||||||
|
## 2. Konfiguration
|
||||||
|
|
||||||
|
`.env` der Laravel-App:
|
||||||
|
```dotenv
|
||||||
|
LOCAL_AI_URL=http://100.73.82.219:8800/v1
|
||||||
|
LOCAL_AI_API_KEY=<KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/ai.php`:
|
||||||
|
```php
|
||||||
|
'providers' => [
|
||||||
|
// Text, Agent, Embeddings, Transkription
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'openai-compatible',
|
||||||
|
'url' => env('LOCAL_AI_URL'),
|
||||||
|
'key' => env('LOCAL_AI_API_KEY'),
|
||||||
|
'models' => [
|
||||||
|
'text' => ['default' => 'qwen3.5-35b-a3b'],
|
||||||
|
'embeddings' => ['default' => 'nomic-embed', 'dimensions' => 768],
|
||||||
|
'transcription' => ['default' => 'whisper'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Bilder: openai-compatible kann keine Bilder, der OpenAI-Treiber schon.
|
||||||
|
// Er schickt "gpt-image-1" & Co. – der Server kennt diese Namen als Aliase.
|
||||||
|
'local-images' => [
|
||||||
|
'driver' => 'openai',
|
||||||
|
'key' => env('LOCAL_AI_API_KEY'),
|
||||||
|
'url' => env('LOCAL_AI_URL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
`local` in `config/ai.php` als Standard für Text, Embeddings und Transkription eintragen und `local-images` als Standard für Bilder. Die genauen Schlüssel für die Standard-Provider stehen in der veröffentlichten `config/ai.php`.
|
||||||
|
|
||||||
|
## 3. Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:agent Assistant
|
||||||
|
```
|
||||||
|
```php
|
||||||
|
$response = (new Assistant)->prompt('Fasse die offenen Rechnungen zusammen', timeout: 300);
|
||||||
|
|
||||||
|
// im Controller als Stream (SSE) an den Browser
|
||||||
|
return (new Assistant)->stream($text);
|
||||||
|
```
|
||||||
|
- **Timeouts großzügig setzen.** Das Laden eines Modells kann einige Sekunden bis Minuten dauern. Längere Agent-Läufe besser als Queue-Job.
|
||||||
|
- Tool-Calling klappt mit `qwen3.5-35b-a3b` und `glm-4.7-flash` zuverlässig, mit `qwen3.5-9b` bei mehrstufigen Aufgaben schlechter.
|
||||||
|
- Alle Anwendungen sollten dasselbe Chat-Modell nutzen (opencode, Continue, Laravel). Ein anderes Modell löst bei jedem Wechsel einen Modellwechsel aus.
|
||||||
|
|
||||||
|
## 4. Spracheingabe (Aufnahmetaste)
|
||||||
|
|
||||||
|
Ablauf: Browser nimmt auf → `POST /voice/transcribe` → Text anzeigen und bestätigen → `POST /voice/ask` → Agent streamt die Antwort.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Ai\Agents\Assistant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Ai\Transcription;
|
||||||
|
|
||||||
|
Route::post('/voice/transcribe', function (Request $request) {
|
||||||
|
$request->validate(['audio' => 'required|file|max:20480']);
|
||||||
|
|
||||||
|
return ['text' => trim((string) Transcription::fromUpload($request->file('audio'))->generate())];
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::post('/voice/ask', function (Request $request) {
|
||||||
|
$request->validate(['text' => 'required|string|max:4000']);
|
||||||
|
|
||||||
|
return (new Assistant)->stream($request->string('text'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
let rec, chunks = [];
|
||||||
|
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/mp4';
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
rec = new MediaRecorder(stream, { mimeType: mime });
|
||||||
|
chunks = [];
|
||||||
|
rec.ondataavailable = e => chunks.push(e.data);
|
||||||
|
rec.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('audio', new Blob(chunks, { type: mime }), mime.includes('webm') ? 'voice.webm' : 'voice.m4a');
|
||||||
|
const res = await fetch('/voice/transcribe', {
|
||||||
|
method: 'POST', body: fd,
|
||||||
|
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
|
||||||
|
});
|
||||||
|
const { text } = await res.json();
|
||||||
|
showForConfirmation(text); // eigenes UI: Text anzeigen/bearbeiten, dann an /voice/ask senden
|
||||||
|
};
|
||||||
|
rec.start();
|
||||||
|
}
|
||||||
|
const stopRecording = () => rec?.stop();
|
||||||
|
// Taste: pointerdown → startRecording(), pointerup → stopRecording()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **HTTPS ist Pflicht.** Browser geben das Mikrofon nur auf `https://` oder `localhost` frei. Das betrifft die Laravel-App, nicht die Verbindung von Laravel zum KI-Server.
|
||||||
|
- **Formate:** Chrome/Firefox liefern WebM/Opus, Safari MP4/AAC. Der Server wandelt alles per ffmpeg selbst um, Laravel muss nichts konvertieren (getestet: WAV, WebM, M4A).
|
||||||
|
- **Sprache:** Der Server erkennt die Sprache automatisch.
|
||||||
|
- **Bestätigung vor dem Absenden:** Whisper verhört sich manchmal bei Namen und Fachbegriffen. Bei Agenten, deren Tools etwas ausführen, den Text deshalb erst anzeigen.
|
||||||
|
|
||||||
|
## 5. Bilder
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Ai\Image;
|
||||||
|
|
||||||
|
$path = Image::of('Ein rotes Fahrrad vor einer gelben Hauswand')
|
||||||
|
->landscape()
|
||||||
|
->timeout(300)
|
||||||
|
->generate() // mit 'local-images' als Standard-Provider für Bilder
|
||||||
|
->store();
|
||||||
|
```
|
||||||
|
- `square()` = 1024×1024 (~40 s), `landscape()`/`portrait()` = 1536×1024 (~65 s). `quality()` wird ignoriert.
|
||||||
|
- Bilder am besten in einem Queue-Job erzeugen (`->queue()->then(...)`), nicht im Web-Request.
|
||||||
|
- Als **Agent-Tool**: `php artisan make:tool GenerateImage`. In `handle()` das Bild erzeugen, speichern und die URL zurückgeben. Bedenken: Danach muss das Chat-Modell neu geladen werden (~5 s), bevor der Agent weiterantwortet.
|
||||||
|
- Falls der OpenAI-Treiber in einer neueren SDK-Version Parameter schickt, die der Server ablehnt, geht es auch direkt:
|
||||||
|
```php
|
||||||
|
$b64 = Http::withToken(config('ai.providers.local.key'))->timeout(300)
|
||||||
|
->post(config('ai.providers.local.url').'/images/generations', [
|
||||||
|
'model' => 'z-image', 'prompt' => $prompt, 'size' => '1024x1024',
|
||||||
|
])->json('data.0.b64_json');
|
||||||
|
Storage::put($path, base64_decode($b64));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| 401 | API-Key falsch |
|
||||||
|
| 403 | Laravel-Server nicht in `ALLOWED_NETS` |
|
||||||
|
| Timeout beim ersten Request | Modell lädt noch → Timeout erhöhen |
|
||||||
|
| Bild dauert > 1 min | normal bei 1536×1024 und nach einem Modellwechsel |
|
||||||
|
| Mikrofon-Abfrage erscheint nicht | Seite läuft nicht über HTTPS |
|
||||||
|
| Leeres Transkript | Aufnahme zu kurz/stumm. Mindestlänge im Frontend prüfen |
|
||||||
|
|
||||||
|
Schnelltest vom Laravel-Server aus:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $KEY" http://<server>:8800/v1/audio/transcriptions -F model=whisper -F file=@test.webm
|
||||||
|
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' http://<server>:8800/v1/images/generations \
|
||||||
|
-d '{"model":"z-image","prompt":"a red bicycle","size":"512x512"}' | head -c 200
|
||||||
|
```
|
||||||
29
llama-swap/Dockerfile
Normal file
29
llama-swap/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# llama-swap mit ROCm (LLMs) + whisper-server (Transkription) + sd-server (Bilder)
|
||||||
|
#
|
||||||
|
# Das offizielle unified-Image mit whisper-/sd-server gibt es für AMD nur als
|
||||||
|
# Vulkan-Build, und unter Vulkan ist die Prompt-Verarbeitung von llama.cpp auf
|
||||||
|
# der RX 6950 XT nur halb so schnell wie unter ROCm (siehe PLAN-multimodal.md).
|
||||||
|
# Deshalb: ROCm-Image als Basis, die beiden Server aus dem unified-Image dazu.
|
||||||
|
# Beide Basis-Images sind Ubuntu 24.04 mit identischen Mesa-/Vulkan-Paketen.
|
||||||
|
ARG LLAMA_SWAP_TAG
|
||||||
|
ARG UNIFIED_TAG
|
||||||
|
|
||||||
|
FROM ghcr.io/mostlygeek/llama-swap:${UNIFIED_TAG} AS unified
|
||||||
|
|
||||||
|
FROM ghcr.io/mostlygeek/llama-swap:${LLAMA_SWAP_TAG}
|
||||||
|
|
||||||
|
# Vulkan-Treiber (RADV) für sd-server; ffmpeg ist schon enthalten (whisper --convert)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# sd-server ist statisch gegen ggml gelinkt
|
||||||
|
COPY --from=unified /usr/local/bin/sd-server /usr/local/bin/sd-server
|
||||||
|
|
||||||
|
# whisper-server bringt eigene ggml-Bibliotheken mit, die dieselben Namen tragen
|
||||||
|
# wie die von llama.cpp in /app – daher getrennt ablegen und per Wrapper laden
|
||||||
|
COPY --from=unified /usr/local/bin/whisper-server /opt/whisper/bin/whisper-server
|
||||||
|
COPY --from=unified /usr/local/lib/libwhisper.so* /usr/local/lib/libggml*.so* /opt/whisper/lib/
|
||||||
|
RUN printf '#!/bin/sh\nLD_LIBRARY_PATH=/opt/whisper/lib exec /opt/whisper/bin/whisper-server "$@"\n' \
|
||||||
|
> /usr/local/bin/whisper-server \
|
||||||
|
&& chmod 755 /usr/local/bin/whisper-server
|
||||||
@ -7,7 +7,13 @@
|
|||||||
# - Autocomplete-Modell (~2 GB) bleibt dauerhaft geladen
|
# - Autocomplete-Modell (~2 GB) bleibt dauerhaft geladen
|
||||||
# - genau ein Chat-Modell gleichzeitig; llama.cpp --fit (Standard: an) verteilt
|
# - genau ein Chat-Modell gleichzeitig; llama.cpp --fit (Standard: an) verteilt
|
||||||
# Layer/MoE-Experten automatisch auf VRAM und RAM, --fit-target hält ~3 GB frei
|
# Layer/MoE-Experten automatisch auf VRAM und RAM, --fit-target hält ~3 GB frei
|
||||||
|
# - Whisper (~1,2 GB, Vulkan) bleibt dauerhaft geladen; es wird vor dem
|
||||||
|
# Chat-Modell geladen, --fit rechnet den Platz also automatisch mit ein
|
||||||
# - Embeddings laufen auf der CPU
|
# - Embeddings laufen auf der CPU
|
||||||
|
# - das Bildmodell (z-image) ist in der chat-Gruppe: es verdrängt das Chat-Modell
|
||||||
|
#
|
||||||
|
# whisper-server und sd-server kommen aus dem unified-Image (llama-swap/Dockerfile).
|
||||||
|
# Ihre Modelle liegen fest in ./models/whisper und ./models/sd (kein -hf), siehe README.
|
||||||
|
|
||||||
healthCheckTimeout: 900 # erster Start kann einen 20-GB-Download enthalten
|
healthCheckTimeout: 900 # erster Start kann einen 20-GB-Download enthalten
|
||||||
logLevel: info
|
logLevel: info
|
||||||
@ -61,6 +67,36 @@ models:
|
|||||||
--embedding --pooling mean -c 8192 -b 8192 -ub 8192 -ngl 0 --threads 8
|
--embedding --pooling mean -c 8192 -b 8192 -ub 8192 -ngl 0 --threads 8
|
||||||
ttl: 0
|
ttl: 0
|
||||||
|
|
||||||
|
# --- Transkription (/v1/audio/transcriptions) --------------------------
|
||||||
|
whisper:
|
||||||
|
name: Whisper large-v3-turbo (GPU, Vulkan)
|
||||||
|
# GPU: ~0,5 s für 7 s Sprache, auf der CPU (-ng -t 12) wären es ~3,3 s
|
||||||
|
# --convert: ffmpeg wandelt WebM/Opus, MP4/AAC usw. aus dem Browser um
|
||||||
|
cmd: >-
|
||||||
|
whisper-server --host 127.0.0.1 --port ${PORT}
|
||||||
|
-m /models/whisper/ggml-large-v3-turbo-q8_0.bin
|
||||||
|
--inference-path /v1/audio/transcriptions
|
||||||
|
--convert --tmp-dir /tmp
|
||||||
|
-t 8 -l auto
|
||||||
|
aliases: [whisper-1]
|
||||||
|
ttl: 0
|
||||||
|
|
||||||
|
# --- Bilder (/v1/images/generations), sd-server über Vulkan ------------
|
||||||
|
z-image:
|
||||||
|
name: Z-Image Turbo (Bilder)
|
||||||
|
checkEndpoint: /
|
||||||
|
cmd: >-
|
||||||
|
sd-server --listen-ip 127.0.0.1 --listen-port ${PORT}
|
||||||
|
--diffusion-model /models/sd/z_image_turbo-Q8_0.gguf
|
||||||
|
--vae /models/sd/ae.safetensors
|
||||||
|
--llm /models/sd/Qwen3-4B-Instruct-2507-Q8_0.gguf
|
||||||
|
--diffusion-fa --offload-to-cpu
|
||||||
|
--cfg-scale 1.0 --steps 8
|
||||||
|
--height 1024 --width 1024
|
||||||
|
# Der OpenAI-Treiber (z. B. Laravel AI SDK) schickt seine eigenen Modellnamen
|
||||||
|
aliases: [gpt-image-1, dall-e-3, dall-e-2]
|
||||||
|
ttl: 600
|
||||||
|
|
||||||
# Helfer-Modelle beim Container-Start laden (auch nach Reboot), damit
|
# Helfer-Modelle beim Container-Start laden (auch nach Reboot), damit
|
||||||
# Autocomplete sofort reagiert. Das Chat-Modell lädt beim ersten Request
|
# Autocomplete sofort reagiert. Das Chat-Modell lädt beim ersten Request
|
||||||
# bzw. per scripts/start.sh.
|
# bzw. per scripts/start.sh.
|
||||||
@ -69,6 +105,7 @@ hooks:
|
|||||||
preload:
|
preload:
|
||||||
- qwen2.5-coder-1.5b
|
- qwen2.5-coder-1.5b
|
||||||
- nomic-embed
|
- nomic-embed
|
||||||
|
- whisper
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
router:
|
router:
|
||||||
@ -83,6 +120,7 @@ routing:
|
|||||||
- qwen3.5-35b-a3b
|
- qwen3.5-35b-a3b
|
||||||
- glm-4.7-flash
|
- glm-4.7-flash
|
||||||
- qwen3.5-9b
|
- qwen3.5-9b
|
||||||
|
- z-image
|
||||||
# Helfer bleiben dauerhaft geladen
|
# Helfer bleiben dauerhaft geladen
|
||||||
always-on:
|
always-on:
|
||||||
swap: false
|
swap: false
|
||||||
@ -91,3 +129,4 @@ routing:
|
|||||||
members:
|
members:
|
||||||
- qwen2.5-coder-1.5b
|
- qwen2.5-coder-1.5b
|
||||||
- nomic-embed
|
- nomic-embed
|
||||||
|
- whisper
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
# ./scripts/start.sh --no-preload nur starten
|
# ./scripts/start.sh --no-preload nur starten
|
||||||
# ./scripts/start.sh --model glm-4.7-flash anderes Chat-Modell vorladen
|
# ./scripts/start.sh --model glm-4.7-flash anderes Chat-Modell vorladen
|
||||||
# ./scripts/start.sh --show-key API-Key im Klartext ausgeben
|
# ./scripts/start.sh --show-key API-Key im Klartext ausgeben
|
||||||
# ./scripts/start.sh --pull Images vorher aktualisieren
|
# ./scripts/start.sh --pull Basis-Images aktualisieren und neu bauen
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||||
@ -88,8 +88,12 @@ free_gb=$(df -BG --output=avail models | tail -1 | tr -dc '0-9')
|
|||||||
|
|
||||||
# ------------------------------------------------------------------- Start
|
# ------------------------------------------------------------------- Start
|
||||||
step "Starte Container"
|
step "Starte Container"
|
||||||
(( PULL )) && docker compose pull
|
if (( PULL )); then
|
||||||
docker compose up -d --remove-orphans
|
docker compose pull --ignore-buildable
|
||||||
|
docker compose build --pull
|
||||||
|
fi
|
||||||
|
# baut das llama-swap-Image (ROCm + whisper/sd-server), falls es noch fehlt
|
||||||
|
docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
step "Warte auf Bereitschaft"
|
step "Warte auf Bereitschaft"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
@ -112,16 +116,24 @@ ok "Caddy bereit (Auth aktiv)"
|
|||||||
# ---------------------------------------------------------------- Preload
|
# ---------------------------------------------------------------- Preload
|
||||||
if (( PRELOAD )); then
|
if (( PRELOAD )); then
|
||||||
step "Lade Modelle vor (erster Start lädt ggf. von Hugging Face herunter)"
|
step "Lade Modelle vor (erster Start lädt ggf. von Hugging Face herunter)"
|
||||||
preload() { # $1 = Modell, $2 = Endpoint, $3 = JSON-Body
|
preload() { # $1 = Modell, $2 = Endpoint, Rest = curl-Argumente für den Body
|
||||||
local t0=$SECONDS code
|
local m=$1 ep=$2 t0=$SECONDS code; shift 2
|
||||||
printf ' %-22s … ' "$1"
|
printf ' %-22s … ' "$m"
|
||||||
code=$(curl -s -o /dev/null -m 3600 -w '%{http_code}' -H 'Content-Type: application/json' \
|
code=$(curl -s -o /dev/null -m 3600 -w '%{http_code}' "http://127.0.0.1:$BACKEND_PORT$ep" "$@" || true)
|
||||||
"http://127.0.0.1:$BACKEND_PORT$2" -d "$3" || true)
|
|
||||||
if [[ "$code" == "200" ]]; then echo "${G}ok${N} ($((SECONDS - t0)) s)"; else echo "${R}HTTP $code${N}"; fi
|
if [[ "$code" == "200" ]]; then echo "${G}ok${N} ($((SECONDS - t0)) s)"; else echo "${R}HTTP $code${N}"; fi
|
||||||
}
|
}
|
||||||
preload qwen2.5-coder-1.5b /v1/completions '{"model":"qwen2.5-coder-1.5b","prompt":"x","max_tokens":1}'
|
json=(-H 'Content-Type: application/json' -d)
|
||||||
preload nomic-embed /v1/embeddings '{"model":"nomic-embed","input":"x"}'
|
preload qwen2.5-coder-1.5b /v1/completions "${json[@]}" '{"model":"qwen2.5-coder-1.5b","prompt":"x","max_tokens":1}'
|
||||||
preload "$MODEL" /v1/chat/completions "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1}"
|
preload nomic-embed /v1/embeddings "${json[@]}" '{"model":"nomic-embed","input":"x"}'
|
||||||
|
if [[ -f models/whisper/ggml-large-v3-turbo-q8_0.bin ]]; then
|
||||||
|
wav=$(mktemp --suffix=.wav)
|
||||||
|
python3 -c 'import wave,sys; w=wave.open(sys.argv[1],"wb"); w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(b"\0\0"*16000)' "$wav"
|
||||||
|
preload whisper /v1/audio/transcriptions -F model=whisper -F "file=@$wav"
|
||||||
|
rm -f "$wav"
|
||||||
|
else
|
||||||
|
warn "Whisper-Modell fehlt (models/whisper/) – siehe README"
|
||||||
|
fi
|
||||||
|
preload "$MODEL" /v1/chat/completions "${json[@]}" "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Status
|
# ------------------------------------------------------------------ Status
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user