900 lines
48 KiB
Markdown
900 lines
48 KiB
Markdown
# 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.
|
||
|
||
Der Telefonbuchimport über `POST /api/addresses` aktualisiert Name und Beschreibung,
|
||
erhält aber vorhandene Rollen, Kundenkennungen, Job-Zuordnungen und Sprungadressen.
|
||
Diese Felder werden nur durch vollständige Adressdaten über die Webhooks ersetzt;
|
||
so können dort beispielsweise mit `job_ids: []` alte Job-Zuordnungen entfernt werden.
|
||
|
||
```
|
||
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" }
|
||
```
|
||
|
||
Der generische Webhook nimmt **beliebiges JSON** an – Objekt wie Liste. Ausnahme:
|
||
`"type":"job"` und `POST /api/webhook/jobs` verlangen ein einzelnes Job-Objekt mit
|
||
positiver ganzzahliger `id` im Java-Long-Wertebereich und passenden Feldtypen.
|
||
Ungültige Jobs ergeben **HTTP 400**, werden nicht archiviert und erscheinen nicht im
|
||
Verlauf oder Ereignisstrom. Die Quittung gültiger Daten bestätigt die Annahme;
|
||
die MongoDB-Speicherung läuft weiterhin asynchron.
|
||
|
||
Die Startseite zeigt angenommene Nutzlasten 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 |
|
||
| `POST /api/webhook/jobs` | Einen Job abliefern. Zusätzlich `400` bei fehlender/ungültiger `id` oder unpassenden Feldtypen; gilt auch für `"type":"job"` am generischen Webhook |
|
||
| `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. Nachlieferung und Anmeldung am laufenden Strom sind gegen gleichzeitige
|
||
POSTs abgesichert; Ereignisse bleiben dabei in Id-Reihenfolge.
|
||
Ü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
|
||
einschließlich `APP_WEBSOCKET_PATH` und `APP_WEBSOCKET_SECURE`. Auch `APP_WEBHOOK_TOKEN`
|
||
und `SPRING_MONGODB_URI` werden an den Container weitergereicht. 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:<recordingId>`.
|
||
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 <LLM_BASE_URL>/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
|
||
```
|