# 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= ``` `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://:8800/v1/audio/transcriptions -F model=whisper -F file=@test.webm curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' http://:8800/v1/images/generations \ -d '{"model":"z-image","prompt":"a red bicycle","size":"512x512"}' | head -c 200 ```