# SwyxWeb React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus** per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende Anrufe und startet ausgehende Anrufe. Standardmäßig öffnet sich ein **Dashboard mit der Historie eingegangener Anrufe**: Rufnummer, Name (falls übermittelt), Datum, Uhrzeit und Leitung, neueste zuerst. Die Historie lässt sich nach Rufnummer oder Name durchsuchen; Kennzahlen zeigen alle eingegangenen Anrufe, die heutigen Anrufe und unterschiedliche Rufnummern. `GET /api/calls` liest dafür die gespeicherten `incoming`-Ereignisse aus MongoDB, auch aus früheren Sitzungen. Das Dashboard aktualisiert sich alle fünf Sekunden. Verbindungs- und Ende-Ereignisse erscheinen nicht als zusätzliche Anrufe. Der Schalter **Dev** oben in der Kopfzeile blendet die bisherige Oberfläche mit Anrufen, Adressdaten, Webhook, Tabs und Verbindung ein. Er ist beim Laden der Seite ausgeschaltet. Beim Umschalten bleiben Eingaben und die SwyxTray-Verbindung erhalten; Anruf-Popups und Benachrichtigungen funktionieren auch im Dashboard. Job-Datensätze unterstützen zusätzlich zur Auftragsadresse `url` die optionale Kartenadresse `map_url`, beispielsweise `"map_url": "https://vhs.assecutor.org/?jb=21891233&cr=13385"`. Ist eine HTTP(S)-Kartenadresse vorhanden, erscheint im Anruf-Popup neben dem zugeordneten Job **Karte öffnen**. Ein Klick öffnet diese Adresse in einem neuen Browser-Tab. Bei fehlender, leerer oder ungültiger Adresse bleibt der Knopf ausgeblendet. Das Backend übernimmt `map_url` sowohl über `POST /api/webhook/jobs` als auch über `POST /api/webhook` mit `"type": "job"` in die MongoDB-Sammlung `jobs`. Dort heißt das Feld ebenfalls `map_url`; `GET /api/jobs/{id}` gibt den gespeicherten Wert zurück. Ein erneut gesendeter Job ersetzt den bisherigen Datensatz einschließlich der Kartenadresse. Bestehende Jobs ohne Kartenadresse bleiben lesbar; zum Ergänzen die vollständigen Jobdaten mit `map_url` erneut senden. Eine Schema-Migration ist nicht erforderlich. ``` SwyxWeb/ ├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) └── frontend/ React 19 + TypeScript + Vite ``` ## Wer verbindet sich mit wem? Die WebSocket-Verbindung wird ausschließlich im Browser aufgebaut – in [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts) über `new WebSocket(url)`. Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben: 1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)). 2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie an die offenen Browser weiter (siehe [Webhook](#webhook-für-adressdaten-fremder-systeme)). 3. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne echte Telefonanlage testen zu können (siehe unten). Der läuft **nur mit dem Spring-Profil `mock`**; im Normalbetrieb – und damit auch im Container – gibt es ihn nicht. **Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock), `17654` der Port der SwyxTray-App auf dem Arbeitsplatz des Anwenders. Sie sind unabhängig voneinander. ## Protokoll der SwyxTray-App Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache **`protocol: 8`**; die älteren Nachrichten sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts). Leitungen werden über die **1-basierte Leitungsnummer `line`** adressiert. Seit Protokoll **10** tragen Snapshots und Anrufereignisse außerdem `callId` aus dem SDK. Aufnahmebefehle verlangen beide Werte, damit verspätete Befehle keinen Folgeanruf auf derselben Leitung treffen. Die App schickt vollständige `snapshot`-Nachrichten und zusätzlich `call`-Ereignisse (`incoming`, `outgoing`, `connected`, `ended`). SwyxWeb verarbeitet auch ältere Versionen ausschließlich über die Snapshots. Gesprächsenden werden bei beiden Nachrichtenreihenfolgen nur einmal in den Verlauf übernommen. ### Begrüßung (App → Seite, beim Verbinden) ```json { "app": "SwyxTray", "version": "1.0.0.0", "protocol": 1, "session": 7, "type": "hello" } ``` Direkt danach folgt der erste Snapshot. ### Snapshot (App → Seite, bei jeder Änderung) ```json { "connected": true, "serverUp": true, "overall": "Ringing", "statusText": "Eingehender Ruf", "user": "Muster, M.", "server": "127.0.0.1", "type": "snapshot", "lines": [ { "line": 1, "state": "LSRinging", "stateCode": 3, "stateText": "klingelt", "peer": "001602107449", "peerNumber": "001602107449", "peerName": "", "busy": true, "selected": true }, { "line": 2, "state": "LSInactive", "stateCode": 0, "stateText": "frei", "peer": "unbekannt", "peerNumber": "", "peerName": "", "busy": false, "selected": false } ] } ``` `state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den Klartext aus SwyxIt!. Belegt sind bisher `LSInactive / 0 / "frei"` und `LSRinging / 3 / "klingelt"`. [callStateOf](frontend/src/swyx/protocol.ts) bildet beides auf `incoming` · `outgoing` · `connected` ab und wertet dabei **Name und Klartext** aus; ein unbekannter Zustand gilt als **belegt**, damit eine klingelnde Leitung nie stillschweigend verschwindet. `peer` ist die fertige Anzeigeform, `"unbekannt"` der Platzhalter für „keine Gegenstelle". Freie Leitungen schickt die App immer mit; die Startseite blendet sie aus. ### Kommandos (Seite → App) ```jsonc // -> { "id": 7, "cmd": "call", "number": "+49 30 1234567" } // <- Quittung, id gespiegelt { "id": 7, "ok": true, "line": 2, "type": "result" } // <- Fehlerfall (ungültige Nummer, keine Verbindung zu SwyxIt!) { "id": 7, "ok": false, "error": "…", "type": "result" } ``` `ok: true` heißt **nur**, dass CLMgr den Auftrag angenommen hat – gewählt wird asynchron. Der tatsächliche Verlauf kommt danach über die folgenden Snapshots. Die Startseite weist im Wählbereich darauf hin. | Kommando | Felder | Zweck | Antwort | |------------|----------------|-------------------------------------------|-----------------------------| | `call` | `number` | Wählvorgang starten | `line` | | `answer` | `line` | Anruf annehmen | – | | `hangup` | `line` | Anruf beenden/ablehnen | – | | `ping` | – | Verbindungstest | – | | `status` | – | Snapshot anfordern | – | | `focus` | `title`, `url` | Fenster in den Vordergrund holen | `focused` | | `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` | | `opentab` | `url` | Neuen Tab öffnen | `tabId` | | `closetab` | `tabId` | Tab schließen | – | | `contacts` | `query` | Adressdaten des Swyx-Clients durchsuchen | `contacts` | | `addresses`| – | Adress-Cache der App am Stück abrufen | `addresses` | Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit `"Feld 'cmd' fehlt."`. ### Fenster in den Vordergrund holen ```jsonc // -> { "id": 9, "cmd": "focus", "title": "Kundenakte Muster GmbH", "url": "https://crm.example.local/kunden/4711" } // <- Fenster war da und ist jetzt vorn { "type": "result", "id": 9, "ok": true, "focused": true } // <- kein Fenster gefunden, stattdessen Browser mit der URL gestartet { "type": "result", "id": 9, "ok": true, "focused": false } ``` `title` wird als **Teilzeichenkette** gesucht, unabhängig von der Groß-/Kleinschreibung – `"swyxit"` findet also das Fenster „SwyxIt!". `url` ist optional und wird nur benutzt, wenn kein Fenster passt; fehlt sie in diesem Fall, antwortet die App mit `ok: false` und `"Kein Fenster mit Titel '…' gefunden und keine URL angegeben."`. Ein fehlender oder leerer Titel ergibt `"Feld 'title' fehlt."`. Seit der Umstellung des Tab-Bereichs benutzt die Startseite `focus` nicht mehr; das Kommando bleibt aber im Protokoll und in `SwyxTrayClient.focusWindow()` erhalten. ### Tabs des Firefox-Plugins Die drei Tab-Kommandos reicht die SwyxTray-App **unverändert an das Firefox-Plugin durch** – gemeint sind also die Tabs des Firefox auf dem Rechner der App, nicht die Bereiche der Startseite. Es sind dieselben Kanäle, die auch die Tray-Menüpunkte *Lese Tabs*, *Tab öffnen* und *Tab schliessen* benutzen. ```jsonc // -> { "id": 12, "cmd": "tabs" } // <- { "type": "result", "id": 12, "ok": true, "tabs": [ { "id": 43, "title": "Kundenakte", "url": "https://crm.example.local/kunden/4711", "active": true }, { "id": 44, "title": "SwyxWeb", "url": "http://localhost:5173/", "active": false } ] } // -> { "id": 13, "cmd": "opentab", "url": "https://crm.example.local/kunden/4711" } // <- { "type": "result", "id": 13, "ok": true, "tabId": 43 } // -> tabId stammt aus einer tabs-Antwort { "id": 14, "cmd": "closetab", "tabId": 43 } // <- { "type": "result", "id": 14, "ok": true } ``` `url` muss eine **absolute http(s)-Adresse** sein – dieselbe Prüfung wie bei `focus`; sonst antwortet die App mit `ok: false`. Ist **kein Plugin verbunden**, endet jedes der drei Kommandos nach dem Zeitfenster von rund fünf Sekunden ebenfalls mit `ok: false`. Deshalb wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden ([SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts)). Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem Öffnen und Schließen neu über `tabs` geholt. ### Adressdaten des Swyx-Clients Seit Protokoll 7 gibt die App das globale Telefonbuch der Anlage heraus – dieselben Einträge, die SwyxIt! im Adressbuch zeigt. ```jsonc // -> { "id": 20, "cmd": "contacts", "query": "abt" } // <- { "type": "result", "id": 20, "ok": true, "contacts": [ { "name": "Abt, Bettina", "number": "7587", "description": "S-SB" }, { "name": "Abdul, Rokhsareh", "number": "5215", "description": "" } ] } ``` Wichtig für die Anzeige – alles gegen die laufende App sondiert: - **`query` ist Pflicht.** Ein fehlender, leerer oder nur aus Leerzeichen bestehender Begriff und einer über 128 Zeichen ergeben dieselbe Meldung: `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."` Ein **vollständiges Auflisten gibt es nicht**; der Bereich der Startseite ist deshalb eine Suche. - Gesucht wird als **Teilzeichenkette in Name und Rufnummer**, Groß-/Kleinschreibung egal. Die `description` (Standortkürzel wie „HH-MT") wird **nicht** durchsucht. - Sortiert nach Namen, **höchstens 100 Treffer** – gekürzt wird stillschweigend, es gibt kein Feld dafür. Ein `limit` im Kommando ignoriert die App. - `description` ist bei vielen Einträgen `""`. Namen sind **nicht eindeutig**: dieselbe Person kommt mit mehreren Durchwahlen vor. - `query` muss eine **Zeichenkette** sein; eine Zahl quittiert die App mit `"Ungueltiges JSON."` – und zwar **ohne `id`**, die Antwort lässt sich also keinem Kommando zuordnen. `SwyxTrayClient.searchContacts()` schickt deshalb immer Text. Die `number` ist die Durchwahl in der Form, die `call` direkt annimmt – die Einträge im Bereich „Adressdaten" haben darum einen „Anrufen"-Knopf. ### Adress-Cache (Protokoll 8) Seit Protokoll 8 hält die App die Adressdaten selbst vor und gibt sie **am Stück** heraus – ohne Suchbegriff und ohne Deckel. Das ersetzt im Normalfall die 110 Einzelabfragen des nächsten Abschnitts. ```jsonc // -> { "id": 5, "cmd": "addresses" } // <- { "type": "result", "id": 5, "ok": true, "addresses": [ { "name": "Muster GmbH", "number": "+493012345", "description": "Globales Telefonbuch" } ] } ``` Dieselben Daten schickt die App außerdem **unaufgefordert**, direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert: ```json { "type": "addresses", "addresses": [ … ] } ``` Diese Push-Nachricht ist die **einzige Nachricht der App ohne `id` und ohne `ok`** – sie ist damit keine Quittung und wird von [parseAddresses](frontend/src/swyx/protocol.ts) gelesen, nicht von `parseResult`. Die Einträge tragen dieselben Felder wie bei `contacts`. Ein **leeres** `addresses` ist ein gültiger Zustand und heißt „Cache ist leer" – nicht „keine Antwort". Stand 21.08.2026 meldet die Anlage genau das: das Kommando wird mit `ok: true` beantwortet, der Cache ist aber leer, und der Push beim Verbinden trägt `[]`. Deshalb bleiben die Einzelabfragen als Rückfallebene bestehen. ### Rückfallebene: den Gesamtbestand aus Einzelabfragen holen Kennt die App das Kommando `addresses` nicht oder ist ihr Cache leer, setzt [directory.ts](frontend/src/swyx/directory.ts) den Bestand aus vielen `contacts`-Abfragen zusammen. Der Trick steckt in der Wahl der Suchbegriffe: > Jeder Eintrag hat eine Rufnummer, und die ist eine **reine Ziffernfolge**. Eine Nummer mit > mindestens zwei Ziffern enthält deshalb mindestens eines der hundert Paare `"00"`…`"99"`. > Die Vereinigung dieser hundert Abfragen ist damit **nachweislich vollständig** – solange > keine davon am Deckel hängt. Dazu kommen die zehn einstelligen Abfragen `"0"`…`"9"`: eine einstellige Rufnummer (etwa `0` für eine Zentrale) käme in keinem Paar vor. Macht **110 Abfragen**. Gegen die Anlage gemessen (21.08.2026): | | | |---|---| | Einträge insgesamt | 404 | | Abfragen | 110 (keine Verfeinerung nötig) | | größte Trefferzahl eines Ziffernpaars | 59 – also weit unter dem Deckel von 100 | | einstellige Abfragen am Deckel | 7 von 10 (deshalb genügen sie allein nicht) | | Dauer | rund 6,7 s | | Rufnummern | durchgehend 4- bis 6-stellig, rein numerisch | Die App beantwortet die Abfragen **nacheinander**; mehrere gleichzeitig zu schicken bringt kaum etwas (110 Abfragen: 7,2 s einzeln, 6,3 s zu viert). Deshalb der Wartedialog. **Was passiert, wenn doch eine Abfrage gekürzt wird?** Dann verfeinert der Durchlauf sie – vorn *und* hinten je eine Ziffer angehängt, denn eine Teilzeichenkette kann an beiden Enden weitergehen. Das erfasst jeden Eintrag, dessen Rufnummer **länger** ist als die gekappte Abfrage. Bleibt eine Abfrage auch dreistellig noch gekürzt, meldet der Bereich den Bestand ausdrücklich als unvollständig, statt eine gekürzte Liste als vollständig auszugeben. Die eine verbleibende Lücke, offen benannt: Ein Eintrag, dessen Rufnummer **genau** einer gekappten Abfrage entspricht, ließe sich nicht nachladen. Bei vier- bis sechsstelligen Rufnummern betrifft das nur den Fall einer einstelligen Rufnummer bei gleichzeitig gekappter einstelliger Abfrage. Die Regeln des Protokolls stehen in [protocol.test.ts](frontend/src/swyx/protocol.test.ts), die des Gesamtabrufs in [directory.test.ts](frontend/src/swyx/directory.test.ts) – darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs. ## Funktionen der Startseite Die Seite ist in fünf Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): | Tab | Inhalt | |---|---| | **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse | | **Adressdaten** | Der **gesamte** Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt – dann steht er sofort und ohne Wartedialog. Sonst über `addresses`, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl **und** Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (`call`) | | **Webhook** | Was ein fremdes System an `POST /api/webhook` geschickt hat – jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, **unabhängig vom geöffneten Bereich**: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten | | **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) | | **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden | Die **Meldung eines eingehenden Anrufs** steht bewusst *über* den Tabs – mit Leitungsnummer, Name und Rufnummer, „Annehmen"/„Ablehnen" –, damit sie in keinem Bereich untergeht. Liegt der Browser-Tab im Hintergrund, kommt zusätzlich eine System-Benachrichtigung (nach Erlaubnis). Weiteres: - Der Verlauf entsteht aus dem Vergleich aufeinanderfolgender Snapshots. - Eine Nachricht, die keinem der drei Typen entspricht, wird im Log ausdrücklich als „nicht erkannt" vermerkt statt still verworfen. - Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. ## Webhook für Adressdaten fremder Systeme Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt sie im Bereich **Webhook** an. Anders als alles Übrige läuft das **nicht** über die SwyxTray-App, sondern allein über das Backend. ```bash curl -X POST https://swyxweb.appcreation.de/api/webhook \ -H "Content-Type: application/json" \ -H "X-Webhook-Token: …" \ -d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}' ``` Die Quittung nennt die vergebene Nummer und die Eingangszeit: ```json { "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" } ``` **Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge ([summarize](frontend/src/webhook.ts)). | Endpunkt | Zweck | |---|---| | `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token | | `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) | | `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht | | `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" | ### Wie die Nachricht in den Browser kommt Über **Server-Sent Events**, nicht über den WebSocket: Der gehört der SwyxTray-App. Die Startseite hält eine `EventSource` auf `/api/webhook/events` offen ([useWebhook.ts](frontend/src/hooks/useWebhook.ts)) – einseitig, über dieselbe HTTPS-Verbindung wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf. Zwei Vorkehrungen, damit dabei nichts verloren geht: - Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)). Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**. - Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die ruhende Verbindung nicht kappt. Die Antwort trägt außerdem `X-Accel-Buffering: no` und `Cache-Control: no-cache`. **Ohne den ersten Kopf sammelt nginx die Ereignisse in seinem Puffer** und gibt sie erst später aus – die Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die Pufferung für `/api/webhook/events` abgeschaltet sein (bei Apache `mod_proxy`: `flushpackets=on`). Doppelte Nachrichten sind dadurch normal – der Bereich holt beim Start den Verlauf *und* hört auf den Strom. Entschieden wird über die `id` ([mergeEvent](frontend/src/webhook.ts)). ### Token ```properties app.webhook.token= # leer ⇒ jeder Aufruf wird angenommen app.webhook.history=50 # so viele Nachrichten bleiben im Speicher app.webhook.max-size=262144 # größte Nutzlast in Byte ``` > **Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein.** `/api/webhook` > liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar; > ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das > Backend beim Start ausdrücklich darauf hin (`WARN`). Im Container: ```bash docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13 ``` Das Token gilt nur für `POST`. Verlauf und Ereignisstrom bleiben offen – sie liefern dasselbe, was die Seite ohnehin jedem Betrachter zeigt. ## Starten ### Aus VS Code Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Frontend)"** wählen ([launch.json](.vscode/launch.json)). Sie startet das Backend im Debugger, danach den Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf beiden Seiten. „Stop" beendet beides zusammen. Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem Profil `mock`, `/api/config` liefert dann `ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im VS Code enthalten, für Vite und Chrome). ### Backend ```bash cd backend ./mvnw spring-boot:run ``` Läuft auf `http://0.0.0.0:8080`. Achtung: Auf diesem Rechner belegt bereits ein anderes Projekt Port 8080 – dann auf einen freien Port ausweichen: ```bash ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081" ``` Der Port der SwyxTray-App (17654) ändert sich dadurch nicht. ### Frontend ```bash cd frontend npm install npm run dev ``` Öffnet `http://localhost:5173`. Der Vite-Dev-Server leitet `/api` an `http://localhost:8080` weiter (überschreibbar per `VITE_BACKEND_URL`). Die WebSocket-Verbindung läuft **nicht** über diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App. ## Ohne echte Telefonanlage testen > **Der Mock ist Opt-in.** Sein WebSocket-Endpunkt `/ws` und seine Steuer-Endpunkte > `/api/mock/**` hängen am Spring-Profil **`mock`** > ([MockProfile](backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java)) – > ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der > Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das > Profil bewusst nicht. Einschalten: > > ```bash > cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock > java -jar app.jar --spring.profiles.active=mock # oder SPRING_PROFILES_ACTIVE=mock > ``` > > Abgesichert ist beides durch je einen Test in > [BackendApplicationTests](backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java) > (ohne Profil keine Mock-Bohne) und > [MockProfileTests](backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java) > (mit Profil alle da). Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java) spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim Verbinden, Quittungen auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab`/`contacts`/`addresses` und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: ```bash curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" ``` Für `focus` täuscht der Mock die Fenster **„SwyxIt!"**, **„SwyxWeb – Google Chrome"** und **„Kundenakte Muster GmbH"** vor (Teilzeichenkette, Schreibweise egal). Ein passender Titel ergibt `focused: true`, ein unbekannter mit URL `focused: false`, ein unbekannter ohne URL denselben Fehler wie die echte App. Geöffnet wird natürlich nichts – der Mock protokolliert nur. Für die Tab-Kommandos spielt der Mock das Firefox-Plugin und führt eine Tabliste im Speicher – anfangs **„SwyxWeb"** und **„Kundenakte Muster GmbH"**. `opentab` hängt einen Tab an und liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt `"Kein Tab mit der Kennung …"`, eine relative Adresse denselben Fehler wie die echte App. Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; er antwortet immer sofort. Für `contacts` führt der Mock ein Telefonbuch mit **154 Einträgen** – groß genug, dass der Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle sind absichtlich dabei: zweimal **„Muster, Max"** mit verschiedenen Durchwahlen (Namen sind nicht eindeutig), Einträge **ohne Beschreibung**, und **„Zentrale" mit der Rufnummer `0`** – die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden. Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer, Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext für einen fehlenden, leeren oder zu langen Suchbegriff. Denselben Bestand gibt der Mock als Adress-Cache heraus – auf `addresses` und beim Verbinden als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich wie die Anlage zurzeit: ```bash curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false" # -> Einzelabfragen curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true" # -> Cache ``` Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push. Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld eintragen oder das Backend mit `--spring.profiles.active=mock --app.websocket.host=localhost --app.websocket.port=8080` starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch). ## Konfiguration der WebSocket-Adresse Es gilt die erste Quelle, die etwas liefert: | Priorität | Quelle | Beispiel | |---|---|---| | 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar | | 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` | | 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) | | 4 | Default im Code | `ws://127.0.0.1:17654/ws` | Backend-seitig: ```properties server.port=8080 # Port dieser Anwendung app.websocket.host=127.0.0.1 # SwyxTray-App app.websocket.port=17654 app.websocket.path=/ws app.websocket.secure=false # true ⇒ wss:// ``` ## Build und Tests ```bash cd backend && ./mvnw clean package # erzeugt target/backend-0.0.1-SNAPSHOT.jar cd frontend && npm test && npm run build ``` Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach `backend/src/main/resources/static/` kopieren und neu packen. ## Deployment als Docker-Container Das [Dockerfile](Dockerfile) baut das React-Frontend und das Java-Backend in ein Image mit Java, Python und Whisper. Der Container liefert die Oberfläche und REST-API über Port **8080** aus und läuft als Benutzer `swyx` (UID 10001). Der Healthcheck prüft sowohl `/api/config` als auch den lokalen Whisper-Worker. Die Verarbeitung erfolgt über **WAV → SwyxWeb → Whisper im selben Container → Transkript in SwyxWeb**. Verwendet wird `faster-whisper` mit `large-v3-turbo`, CPU und INT8. Das Modell wird beim Image-Build heruntergeladen und mitgeliefert; zur Laufzeit sind weder Modelldownload noch externer Transkriptionsdienst nötig. Der Worker lauscht ausschließlich auf `127.0.0.1:8765` innerhalb des Containers. Das Java-Backend verwendet dafür `APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765`. WAV-Dateien und Aufträge liegen dauerhaft im Volume unter `/data/transcriptions`. Ein Worker verarbeitet die Aufnahmen nacheinander; unterbrochene Aufträge werden beim nächsten Start wieder aufgenommen. Ist ein LLM konfiguriert, erstellt der Worker anschließend eine Zusammenfassung über dessen OpenAI-kompatible API. Die WAV bleibt lokal; an das konfigurierte LLM wird ausschließlich der Text gesendet. Eine Sprecherzuordnung ist nicht enthalten. Mit [Docker Compose](compose.yaml): ```bash # Einmalig bei einem frischen Checkout; vorhandene .env bleibt erhalten. test -f .env || cp .env.example .env docker compose up -d --build ``` Die lokale `.env` enthält die interne Worker-Adresse, `WHISPER_THREADS` (Standard: 4), die LLM-Einstellungen, den SwyxWeb-Port und die SwyxTray-Adresse. Compose liest sie automatisch; auch die beiden VS-Code- Backend-Startkonfigurationen laden diese Datei. `.env` ist aus Git und Docker-Build-Kontext ausgeschlossen; [.env.example](.env.example) enthält nur öffentliche Standardwerte. Oder direkt: ```bash docker build -t swyxweb . docker run -d -p 8080:8080 \ --env-file .env \ --add-host host.docker.internal:host-gateway \ -e APP_WEBSOCKET_HOST=127.0.0.1 \ -e APP_WEBSOCKET_PORT=17654 \ -v swyxweb-transcriptions:/data/transcriptions \ --name swyxweb swyxweb ``` Die interne Worker-Adresse lautet `APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765`. Für den Container zunächst mindestens 4 GB RAM einplanen; der tatsächliche Bedarf hängt auch von Länge und Format der Aufnahmen ab. Das Image ist durch das mitgelieferte Modell größer und der erste Build benötigt einen Download. Ein anderes CTranslate2-Whisper-Modell kann über die beiden Build-Argumente `WHISPER_MODEL_REPO` und `WHISPER_MODEL_REVISION` ausgewählt werden. Bei einer Unterbrechung bleibt die WAV in der SwyxTray-Warteschlange, bis der Worker die dauerhafte Speicherung mit einer Auftragskennung bestätigt. Das Volume enthält WAV-Dateien, Transkripte und Zusammenfassungen ohne automatische Löschfrist; `docker compose down` behält es, `docker compose down -v` löscht es. Beide Prozesse werden gemeinsam beendet; beendet sich einer unerwartet, endet auch der Container und Compose kann ihn neu starten. Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar (`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut werden. Der Java-Heap verwendet `-XX:MaxRAMPercentage=25.0`, damit Speicher für Whisper verfügbar bleibt; überschreibbar mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`. In die Registry schieben erledigt [docker_push.sh](docker_push.sh) – es baut für `linux/amd64` und pusht in die Paket-Registry des Gitea nach `gitea.appcreation.de/sven/swyxweb` (`REGISTRY_IMAGE` im Skript). Die **Versionsnummer ist Pflicht** und wird zum Tag des Images; sie wird bewusst nicht aus der `pom.xml` abgeleitet: ```bash docker login gitea.appcreation.de ./docker_push.sh 0.9.13 # baut und pusht :0.9.13 ./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen ``` Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `x.y.z`. Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build. Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners (`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig. Das Laufzeit-Image samt Python-Abhängigkeiten wird für die Zielarchitektur gebaut; der Modelldownload läuft ebenfalls auf der Architektur des bauenden Rechners. **Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws` und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)). Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten: `-e SPRING_PROFILES_ACTIVE=mock`. Zwei Dinge, die der Container nicht lösen kann: - **Läuft die Seite hinter HTTPS**, blockiert der Browser die unverschlüsselte Verbindung zur SwyxTray-App. Dann braucht die App selbst TLS und `APP_WEBSOCKET_SECURE=true` (⇒ `wss://`). - **Die WebSocket-Verbindung baut der Browser des Anwenders auf**, nicht der Container. Port 17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host. ## Hinweise - **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte `ws://`-Verbindungen (Mixed Content). Im Dev-Betrieb über `http://` ist `ws://` in Ordnung. - **CORS/Origin**: Der Mock akzeptiert alle Origins (`setAllowedOriginPatterns("*")`), damit der Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken – siehe [WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java) und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java). - **Firewall**: Die Verbindung geht per Vorgabe an `127.0.0.1`, bleibt also auf dem Rechner und braucht keine Freigabe. Zeigt die Adresse auf einen anderen Rechner, muss dort Port 17654 eingehend freigegeben sein – sonst scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung. - **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und funktionieren nur über `https://` oder `localhost`. ## Gesprächsaufnahme (Protokoll 10) **Annehmen** in der Leitungsliste oder im Kundenpopup startet nach bestätigter Sprechverbindung automatisch eine Aufnahme. SwyxWeb sendet dafür zuerst `answer` und danach `recording.start` für dieselbe `line` und `callId`. Während eines Gesprächs stehen **Aufnahme starten**, **Aufnahme stoppen** und **Auflegen** auch im Dashboard zur Verfügung. Das Ende erscheint als Hinweis und im Ereignisverlauf. ```json {"id":21,"cmd":"recording.start","line":1,"callId":4711} {"id":22,"cmd":"recording.stop","line":1,"callId":4711} {"type":"call","event":"ended","line":1,"callId":4711,"peerNumber":"123"} ``` Ein Ende, ein anderer Anruf auf derselben Leitung, eine unterbrochene Verbindung oder 15 Sekunden ohne Sprechverbindung brechen den automatischen Start ab. Eine abgewiesene Annahme startet keine Aufnahme. Ein Aufnahmefehler wird angezeigt, ohne die erfolgreiche Annahme als fehlgeschlagen auszugeben. Mit Tray-Versionen vor Protokoll 10 bleibt die Annahme nutzbar; die fehlende Aufnahmefunktion wird angezeigt. Die Tray-App muss nach ihrem Update neu gestartet werden. `recordingRequested` zeigt den zuletzt vom SDK angenommenen Aufnahmeauftrag der Tray-App. Deshalb lautet die Anzeige **Aufnahme angefordert**: Das SDK liefert keinen lesbaren Aufnahmestatus oder Nachweis einer geschriebenen Datei. Änderungen direkt in SwyxIt! sind damit nicht nachweisbar. Dateiname und Speicherort bleiben in der Aufnahmekonfiguration von SwyxIt!; es wird kein Mikrofon des Browsers verwendet. Grundlage sind `IClientLineDisp.DispStartRecording()` und `DispStopRecording()` aus dem [Swyx Client SDK](https://www.swyxdownload.com/download/kb3373_SwyxClientSDKManual.pdf). Der Backend-Mock im Profil `mock` bildet Protokoll 10 mit Anrufkennungen, Aufnahmebefehlen und Ende-Ereignissen ab. Er erzeugt keine Audiodateien. Geprüft: Frontendtests einschließlich asynchroner Annahme, Fehler, Timeout, Leitungswiederverwendung und Verbindungsabbruch; Backend-Protokolltest für Annehmen → Start → Stop → Ende. Ein echter Audio-Mitschnitt muss auf dem Windows-Arbeitsplatz mit eingerichteter SwyxIt!-Aufnahme geprüft werden. ## Automatische WAV-Übergabe nach Gesprächsende Mit SwyxTray **Protokoll 11** läuft die Übergabe automatisch: 1. SwyxTray ordnet dem über `recording.start` gestarteten Aufnahmeabschnitt eine UUID zu. 2. Nach bestätigtem `recording.stop` oder Gesprächsende sucht die App die neue WAV im von SwyxIt! gemeldeten Aufnahmeordner (`IClientConfig.RecordedConversationsPath`). Ein Aufnahmestopp bereitet die Datei nur lokal vor und gibt sie noch nicht frei. 3. Erst nach stabiler Dateigröße, abgeschlossenem RIFF-Dateikopf und exklusivem Lesezugriff erstellt sie eine dauerhafte Kopie in `%LOCALAPPDATA%\SwyxTray\recordings`. 4. Erst nach **bestätigtem Gesprächsende** wird die fertige WAV **über die bereits offene WebSocket-Verbindung** in Blöcken an den Browser geschickt. Es gibt dafür keinen zusätzlichen HTTP-Dateiserver und keine Upload-Zieladresse in SwyxTray. Telefonbefehle bleiben währenddessen möglich. 5. Der Browser übergibt sie an `POST /api/transcriptions` im SwyxWeb-Backend. Dieses leitet sie an `POST /api/jobs` des lokalen Whisper-Workers weiter, mit Sprache `de`, Zusammenfassungsart `style=meeting` und `Idempotency-Key: swyxweb:`. 6. Whisper startet die Transkription automatisch. SwyxWeb fragt den Auftrag alle drei Sekunden ab und zeigt das **Gesprächstranskript direkt aufgeklappt** unter **Aufnahmen · Transkript & Zusammenfassung** an, sobald es vorliegt. Das konfigurierte LLM ergänzt anschließend die Zusammenfassung. Das Transkript bleibt auch sichtbar, wenn die Zusammenfassung fehlschlägt. Ein erneuter Versuch setzt dann direkt bei der Zusammenfassung an, ohne Whisper erneut auszuführen. Der Browser bestätigt SwyxTray die Übergabe erst mit der vom Transkriptionsdienst zurückgegebenen Auftragskennung. Die Tray-App speichert die Bestätigung dauerhaft und entfernt dann ihre temporäre Kopie. **Die originale SwyxIt!-WAV bleibt erhalten.** Ohne Bestätigung bleiben Aufnahmen in der Warteschlange, auch nach einem Neustart. Beim Wiederverbinden werden offene Aufnahmen und die letzten 100 übergebenen Aufträge erneut gemeldet. Dadurch erscheint das Ergebnis auch nach einem Neuladen wieder, sobald der Browser mit derselben Tray-App verbunden ist. Die SwyxWeb-Seite muss für die Übertragung geöffnet und verbunden sein. Übertragungs- und Serverfehler werden alle 15 Sekunden erneut versucht. Der Worker verhindert doppelte Aufträge anhand der UUID, des Dateihashes und der Einstellungen. Dauerhafte Ablehnungen (z. B. ungültige WAV oder Kennungskonflikt) werden angezeigt. Bei einer fehlgeschlagenen Verarbeitung lässt sich der bestehende Auftrag fortsetzen. Ein bloßer COM-/Server-Verbindungsabbruch oder Halten gilt nicht als Gesprächsende. Die Übergabe ist unabhängig davon, wer oder welche Anwendung das Gespräch beendet: Auflegen in SwyxWeb, SwyxIt! oder am Telefon, Auflegen der Gegenstelle und die bestätigte Freigabe oder Wiederbelegung der Leitung lösen denselben Ablauf aus. Ein Auflege- oder Aufnahmestopp-Befehl aus SwyxWeb ist dafür nicht erforderlich. Endet das Gespräch während einer Unterbrechung, wird die wartende Aufnahme nach dem Wiederverbinden und der Bestätigung des Endes automatisch übergeben. Auch nach einem Aufnahmestopp bleiben Download und manuelle Übergabe bis zum Ende des Gesprächs gesperrt. **Auflegen** wird beim Klick deaktiviert und wartet auf die Ende-Bestätigung; eine reine Kommandoquittung reicht nicht. Danach verschwindet das Gespräch aus den belegten Leitungen. Ein verspäteter Auflegebefehl mit alter `callId` kann keine inzwischen neu belegte Leitung beenden. Bereits vorhandene WAV-Dateien werden nicht automatisch gewählt. Ist die Zuordnung mehrdeutig (z. B. mehrere neue Dateien oder überlappende Aufnahmeabschnitte), erscheint statt eines möglicherweise falschen Uploads ein Fehler mit manueller Dateiauswahl. Die Automatik erfasst die über SwyxWeb/Tray gestarteten Aufnahmeabschnitte; direkt in SwyxIt! gestartete Mitschnitte sind diesem Ablauf nicht automatisch zugeordnet. ### Lokale Transkription und LLM-Zusammenfassung Im Docker-Container startet der Whisper-Worker automatisch. Für die Entwicklung ohne Docker kann er in einer eigenen Python-Umgebung gestartet werden; dazu muss ein vollständiges CTranslate2-Whisper-Modell bereits lokal vorliegen: ```sh python3 -m venv transcription/.venv transcription/.venv/bin/pip install -r transcription/requirements.txt WHISPER_MODEL_PATH=/pfad/zum/whisper-modell WHISPER_DATA_DIR=/pfad/zu/auftraegen \ transcription/.venv/bin/python -m uvicorn service:app --app-dir transcription --host 127.0.0.1 --port 8765 ``` Die lokalen VS-Code-Backend-Startkonfigurationen lesen die Worker-Adresse aus der Projektdatei `.env`. Für einen Backend-Start im Terminal aus `backend`: ```sh APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765 ./mvnw spring-boot:run ``` Außerhalb von Docker bleibt der Upload ohne diese Einstellung deaktiviert. Die Seite prüft die Konfiguration alle 30 Sekunden erneut. Die Worker-Adresse gilt aus Sicht des Java-Backends. Die Zusammenfassung wird vollständig in diesem Projekt gesteuert. In `.env` stehen dafür folgende Einstellungen: | Variable | Bedeutung | |---|---| | `LLM_BASE_URL` | OpenAI-kompatible Basis-URL, einschließlich eines benötigten `/v1`-Präfixes | | `LLM_MODEL` | Exakte Modell-ID des gewählten Servers | | `LLM_API_KEY` | Optionaler Bearer-API-Schlüssel; bleibt bei Servern ohne Authentifizierung leer | | `LLM_TIMEOUT_SECONDS` | Zeitlimit pro HTTP-Anfrage, Standard `120` Sekunden | | `LLM_CHUNK_CHARS` | Textlänge pro Abschnitt, Standard `12000` Zeichen; an das Kontextfenster des Modells anpassen | `LLM_BASE_URL` und `LLM_MODEL` müssen gemeinsam gesetzt werden. Beide bleiben in der Vorlage leer: Dann arbeitet Whisper wie bisher ohne Zusammenfassung. Beispiel für ein LLM auf dem Docker-Host: ```dotenv LLM_BASE_URL=http://host.docker.internal:1234/v1 LLM_MODEL=modell-id-des-servers LLM_API_KEY= ``` Für einen anderen OpenAI-kompatiblen Server dessen Basis-URL und Modell-ID eintragen; bei OpenAI lautet die Basis-URL `https://api.openai.com/v1`. Der Worker sendet `POST /chat/completions` mit `model` und `messages` und liest `choices[0].message.content` gemäß der [Chat-Completions-API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create). API-Schlüssel gelangen weder ins Frontend noch ins Image oder in Auftragsdaten. Compose übergibt die Variablen beim Start; Änderungen werden mit `docker compose up -d` übernommen. Beim Python-Start ohne Docker die `LLM_*`-Werte in der Umgebung des Workers setzen; das Java-Backend braucht diese Werte nicht. Lange Transkripte werden abschnittsweise zusammengefasst und anschließend zusammengeführt. Unterstützt werden die Stile `standard`, `short` und `meeting`. Das Transkript wird vor dem LLM-Aufruf dauerhaft gespeichert. Bei Zeitüberschreitung, HTTP-Fehler, unvollständiger LLM-Konfiguration oder ungültiger Antwort wird der Auftrag als fehlgeschlagen angezeigt; **Verarbeitung erneut versuchen** wiederholt nur die Zusammenfassung. Dasselbe gilt nach einem Neustart während der Zusammenfassung. Bereits zuvor abgeschlossene Aufträge werden beim Aktivieren des LLM nicht automatisch erneut verarbeitet. Nach dem Update SwyxTray und den SwyxWeb-Container mit dem neuen Stand starten und die Browserseite neu laden. Protokoll 10 und der Backend-Mock bieten weiterhin manuelle WAV-Zuordnung; der Mock erzeugt keine Audiodateien. | SwyxWeb-Endpunkt | Funktion | |---|---| | `GET /api/transcriptions/config` | Aktivierungszustand und maximale Dateigröße | | `POST /api/transcriptions` | Multipart: `file` (WAV), `recordingId` (UUID), optional `language`, `style` | | `GET /api/transcriptions/{jobId}` | Status, Transkript und optionale Zusammenfassung | | `POST /api/transcriptions/{jobId}/retry` | Fehlgeschlagenen Auftrag fortsetzen | ### WebSocket-Dateiprotokoll 11 `recording.start`/`recording.stop`, ihre Quittungen und Snapshots tragen `recordingId`. Ein doppelter Start liefert die bereits bestehende UUID zurück. Nach Dateifertigstellung und bestätigtem Gesprächsende, beim Verbinden und auf `status` meldet SwyxTray: ```json {"type":"recording.ready","transport":"websocket","recordingId":"12345678-1234-1234-1234-123456789abc","line":1,"callId":4711,"callEnded":true,"filename":"12345678-1234-1234-1234-123456789abc.wav","bytes":320044} ``` Der Browser fordert mit `{"id":23,"cmd":"recording.download","recordingId":"…"}` die Datei an. Jede binäre WebSocket-Nachricht enthält 12 Headerbytes: ASCII `SWYX`, danach Kommando-ID und Byte-Offset als jeweils 32-Bit-Little-Endian, anschließend höchstens 65536 WAV-Bytes. Die abschließende `result`-Quittung bestätigt das Ende des Downloads. Der Browser prüft Reihenfolge und die angekündigte Gesamtlänge (maximal 500 MB). Nach einem Abbruch wird die vollständige Datei erneut angefordert. Nach erfolgreichem Backend-Upload folgt `{"id":24,"cmd":"recording.ack","recordingId":"…","jobId":"<32 Hexzeichen>"}`. `recording.status` meldet `waiting_call_end`, `waiting`, `error` oder `submitted`. `callEnded` bestätigt das Gesprächsende unabhängig vom Aufnahmestopp; vorher weist die Tray-App Downloads und Empfangsbestätigungen zurück. Bei `submitted` steht die Auftragskennung in `jobId`. Mehrere Tabs verwenden denselben Auftrag. Die frühere HTTP-`recording.ready`-Variante wird im Frontend weiterhin gelesen, aber von dieser Tray-Version nicht verwendet. Geprüft sind Windows-Dateisperren, Dateifertigstellung, Mehrdeutigkeiten, Neustart, Binärtransfer über echte WebSockets, Abbruch/Wiederverbindung und Empfangsbestätigung; außerdem Frontend, Java-Weiterleitung und lokale WAV-Verarbeitung. Ein Browsertest lief über die echte Windows-Outbox und WebSocket-Session zur SwyxWeb-API, mit synthetischer WAV und simulierten Modellergebnissen. Ein echter SwyxIt!-Mitschnitt bleibt als Praxistest offen. Für den integrierten Whisper-Worker wurden zusätzlich eine deutsche Sprach-WAV über die SwyxWeb-API in einem Container mit `--network none`, doppelte Uploads, die Wiederholung eines fehlgeschlagenen Auftrags, ein Container-Neustart und ein Worker-Abbruch während der Transkription geprüft. Der unterbrochene Auftrag wurde nach dem Start automatisch abgeschlossen; fertige Transkripte blieben erhalten. Die LLM-Anbindung wurde mit einem lokalen OpenAI-kompatiblen HTTP-Testserver geprüft: Request-Format, optionaler Bearer-Schlüssel, Zeitüberschreitungen, Fehlerantworten, lange Transkripte und Wiederaufnahme der Zusammenfassung. Ein Container-Test mit echtem Whisper und simulierter LLM-Antwort bestätigt, dass ein HTTP-503-Fehler das Transkript erhält und der erneute Versuch ohne erneute WAV-Verarbeitung gelingt. Zusätzlich wurde eine synthetische deutsche Sprach-WAV vollständig über die SwyxWeb-API mit echtem Whisper im Container und einem echten OpenAI-kompatiblen LLM verarbeitet. Transkript und Zusammenfassung wurden dabei erfolgreich erstellt; die Verbindungsdaten bleiben in der lokalen `.env`. Die Worker-Tests einschließlich echter WAV-Dekodierung lassen sich mit Python und den Testabhängigkeiten ausführen: ```sh python3 -m venv transcription/.venv transcription/.venv/bin/pip install -r transcription/requirements-test.txt transcription/.venv/bin/pytest -q transcription/tests ```