Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
988b074478 | ||
|
|
d5eea920a6 | ||
|
|
2c60a19ed6 | ||
|
|
752e2abc5f | ||
|
|
09d06011d5 | ||
|
|
4c0d8ce3fe | ||
|
|
734868efab | ||
|
|
7b46881677 | ||
|
|
cb89ebcd10 | ||
|
|
443b36bb9d | ||
|
|
12b8362d26 | ||
|
|
e628170760 | ||
|
|
7af0b76ad3 | ||
|
|
614ba48531 |
@@ -97,6 +97,7 @@ ARIA hat zwei Rollen:
|
||||
| Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
|
||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
|
||||
| Android-Agent 📱📺 (optional) | Handy, Tablet, Fire TV | `cd host-agent/android && ./build.sh` → APK, oder per Satellit via ADB ausrollen (Details unten) |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
|
||||
@@ -482,11 +483,18 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||
|
||||
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||
|
||||
**Projekt-Kontext**: ein Trigger merkt sich das Projekt, in dem er angelegt wurde (`project_id`), und die Antwort kommt beim Feuern **dort** zurueck. Ohne das landete jede Trigger-Antwort im Hauptchat — im Projekt-Thread blieb es still, und es sah aus wie „der Timer hat nicht gefeuert".
|
||||
|
||||
**Zustand ehrlich**: ein One-Shot-Timer wird nach dem Feuern automatisch deaktiviert. `trigger_list` zeigt darum ausdruecklich `SCHON GEFEUERT (Nx, zuletzt <Zeit>)` statt nur „DEAKTIVIERT" — sonst sind „hat sauber gefeuert" und „war nie an" nicht unterscheidbar, und ARIA berichtet guten Glaubens das Falsche.
|
||||
- **Integrationen → Jellyfin**: beliebig viele Server (`+`-Button) mit URL + API-Key. ARIA steuert die Wiedergabe ueber die **API** (`jellyfin_search` / `jellyfin_devices` / `jellyfin_play` / `jellyfin_control`) statt sich per `ui_tap` durch die Oberflaeche zu klicken — schneller und zuverlaessiger. Eine eingeschlafene Sitzung erkennt ARIA selbst und baut sie neu auf.
|
||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify Default, alle anderen Provider per ARIA on-demand oder "+ Custom"-Button mit auth_url/token_url/scopes) mit client_id+client_secret pro Service + One-Click-Autorisieren + Service-Loeschen, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||
|
||||
### Was zusaetzlich noch drin steckt
|
||||
|
||||
- **Agent-Historie** (Satelliten → Host-Agenten): pro Agent eine aufklappbare Historie der Befehle und Screenshots, gruppiert **pro User-Nachricht** — man sieht, welche Geraete-Aktionen zu welcher Frage gehoerten. Brain schickt dafuer je Host-Aktion ein `agent_history`-Event; die Bridge haelt es und antwortet auf `agent_history_query`.
|
||||
- **Auslastungs-Monitor** pro Compute-Box: Klick auf „Auslastung" zeigt **live `nvidia-smi`** (1s-Takt) plus zwei Verlaufs-Graphen (GPU-Last und Token-Verbrauch, 15s-Sampling, Ringpuffer ~2h) und ein Besen-Symbol zum Zuruecksetzen. Die **Historie liegt auf der Box**, nicht im Diagnostic — abgeholt per RVS (`node_stats_*`), kein SSH und kein Docker-Socket noetig.
|
||||
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
|
||||
@@ -514,6 +522,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
|
||||
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
|
||||
- **Nachricht anstellen statt abbrechen** (Queue pro Projekt): Schickst du eine zweite Nachricht waehrend ARIA noch am aktuellen Task arbeitet, wird sie **angestellt** statt den laufenden abzubrechen — laeuft der Reihe nach, pro Projekt unabhaengig. Wartende zeigen als `⏸`-Bubble (tippen entfernt sie aus der Warteschlange). Explizites Abbrechen laeuft ueber den Stop-Button am „ARIA denkt". Eine neue Sprachnachricht stoppt nur akustisch das TTS (sauberes Mikro), bricht die laufende Arbeit aber nicht mehr ab
|
||||
- **Zwischenruf** (📣): schiebt eine Korrektur in den **laufenden** Turn statt sie anzustellen — ohne Abbruch. Erscheint, sobald ARIA im Kontext arbeitet. Mit Text im Feld geht er sofort raus; **ohne Text schaltet er die naechste Aufnahme scharf**, dann geht der Zwischenruf per Sprache. Der laufende Turn wird dabei nicht gecancelt, nur die Wiedergabe gestoppt (sonst redet ARIA ins offene Mikro)
|
||||
- **Sprachnachricht zu Anhaengen**: Bild anhaengen und den Auftrag dazu **sprechen** statt tippen. Die Dateien gehen beim Antippen des Mikros sofort hoch (Upload laeuft parallel zum Reden); die Bridge fuehrt Anhang und gesprochenen Satz ueber die `clientMsgId` zu **einem** Turn zusammen und legt beides in **eine** Bubble. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz ohne Bild. Das Mikro ist darum immer sichtbar — auch mit Anhang oder getipptem Text
|
||||
- **Teilen-Ziel** (Android-Share): „Teilen → ARIA" aus WhatsApp, Galerie, Browser … → Dialog zeigt *was* geteilt wurde, dann *wohin* (Projekt oder Hauptchat), dann optional ein Text dazu (darf leer bleiben). Die native Seite kopiert die Dateien sofort in den App-Cache, weil die Leseerlaubnis einer `content://`-URI nur fuer diesen Intent gilt und nach der Projektauswahl schon erloschen waere
|
||||
- **Rueckfrage-Loop**: Stellt ARIA eine echte, blockierende Rueckfrage, **pausiert** die Queue und deine naechste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann laeuft der naechste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". ARIA signalisiert das ueber einen unsichtbaren `[[AWAIT]]`-Marker im Antworttext (das Modell deklariert den Zustand selbst, kein „endet-mit-?"-Raten; Brain strippt ihn und gibt `awaiting_reply` an App + Diagnostic durch)
|
||||
- **Pro-Projekt-Textfeld-Entwuerfe**: Der Feldinhalt bleibt beim Projektwechsel erhalten — in Projekt X tippen, zu Y wechseln (leeres Feld), zurueck zu X → dein Entwurf steht wieder da. Persistiert ueber Neustart. Gleiches Verhalten im Diagnostic
|
||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||
@@ -819,6 +830,24 @@ Jeder Dienst haengt an einem Compose-Profil. Ein Node startet ueber
|
||||
| `whisper` | aria-whisper-bridge | STT-Fallback (faster-whisper CUDA) |
|
||||
| `f5tts` | aria-f5tts-bridge | TTS (F5-TTS Voice Cloning) |
|
||||
| `llm` | aria-llama-swap + llm-adapter| Lokales LLM (llama-swap, OpenAI-kompat.) |
|
||||
| `openhands`| aria-openhands | OpenHands gegen das lokale LLM (optional) |
|
||||
|
||||
#### OpenAI-Endpunkt fuers lokale LLM
|
||||
|
||||
Das lokale LLM ist zusaetzlich als **OpenAI-kompatibler Endpunkt** erreichbar —
|
||||
damit laesst sich z.B. OpenHands oder jedes andere OpenAI-SDK darauf richten.
|
||||
Port und Key kommen aus der `.env` der Box (`LLM_OPENAI_PORT`,
|
||||
`LLM_OPENAI_API_KEY`, siehe `xtts/.env.example`).
|
||||
|
||||
> **Die `.env` gewinnt immer.** Steht der Wert dort, sind die Felder in der
|
||||
> Diagnostic **nur lesend** — sonst traegt man dort etwas ein, es wirkt nach dem
|
||||
> naechsten Container-Start nicht, und niemand versteht warum. Ist in der `.env`
|
||||
> nichts gesetzt, laesst sich der Key pro Box in der Diagnostic pflegen
|
||||
> (`llm_openai_config`).
|
||||
|
||||
Der Port ist ein Docker-Port-Mapping und darum **nie** aus der Diagnostic
|
||||
aenderbar. Zur Absicherung nach aussen: SSH-Tunnel statt den Port im LAN
|
||||
aufzureissen (Details in [`xtts/README.md`](xtts/README.md)).
|
||||
|
||||
### Architektur (Aufteilung auf mehrere Nodes)
|
||||
|
||||
@@ -1004,6 +1033,92 @@ keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugri
|
||||
|
||||
> Sichtbar in der Diagnostic unter **Satelliten → Host-Agenten 💻**.
|
||||
|
||||
### GUI-Steuerung (Desktop)
|
||||
|
||||
Auf Linux/Windows/macOS kann der Agent zusaetzlich die **grafische Oberflaeche**
|
||||
bedienen (`host_ui_tap` / `host_ui_text` / `host_ui_swipe` / `host_ui_key` /
|
||||
`host_app_launch`). Das ist **capability-gated**: der Agent meldet beim
|
||||
`host_hello` nur, was er wirklich kann — auf einem Linux-Server ohne X/Wayland
|
||||
fehlt die GUI-Faehigkeit einfach in der Liste, und ARIA sieht das. Eingabe-Weg
|
||||
je System: `windows` · `macos` · `xdotool` (X11) · `ydotool` (Wayland).
|
||||
|
||||
---
|
||||
|
||||
## Android-Agent 📱📺 — Handy, Tablet, Fire TV
|
||||
|
||||
Dieselbe APK laeuft auf **Handy/Tablet** und auf **Fire TV / Android TV**
|
||||
(`host-agent/android/`). Sie ist das Android-Gegenstueck zum Host-Agenten:
|
||||
ausgehende Verbindung zum RVS, kein offener Port, erscheint in der Diagnostic
|
||||
unter **Satelliten → Host-Agenten 💻**.
|
||||
|
||||
**Sehen** — Screenshot (MediaProjection) und Bildschirm-Inhalt als Struktur
|
||||
(`ui_dump` via Accessibility-Service, nur lesend). Beides geht auch bei
|
||||
gesperrtem Geraet.
|
||||
|
||||
**Steuern** — `ui_tap` / `ui_text` / `ui_swipe` / `ui_key` / `app_launch` ueber
|
||||
den Accessibility-Service. **Nur bei entsperrtem Geraet** — Android laesst
|
||||
Gesten auf dem Sperrbildschirm nicht zu; der Agent sagt das klar, statt stumm
|
||||
nichts zu tun. Auf dem Fire TV gibt es keinen Touchscreen, dafuer das D-Pad.
|
||||
|
||||
**Ausgeben** (v.a. Fire TV, dort ist ARIA ein echter Endpunkt):
|
||||
|
||||
| Weg | Was passiert |
|
||||
|-----|--------------|
|
||||
| Einblendung | Text ueber dem laufenden Bild (Overlay, braucht „Ueber anderen Apps anzeigen") |
|
||||
| Sprache | ARIAs **echte** Stimme — der Agent fordert das Audio per `tts_request` ueber den normalen F5-TTS-Pfad an und spielt das PCM ab |
|
||||
| Einstellung | Overlay oder Sprache als Standard, pro Anfrage ueberschreibbar (`mode`) |
|
||||
|
||||
> Fire TV Sticks haben oft **keine deutsche TTS-Engine**. Darum laeuft die
|
||||
> Sprachausgabe bewusst nicht ueber die Geraete-Stimme, sondern ueber ARIAs
|
||||
> eigene. Die Geraete-Stimme gibt es nur noch auf ausdrueckliche Anforderung
|
||||
> (`engine=device`).
|
||||
|
||||
**Audio-Fokus** — bevor ARIA redet, wird der laufende Film leiser (`duck`) oder
|
||||
angehalten (`pause`); danach laeuft er weiter. Sonst redet ARIA mitten in
|
||||
Netflix hinein und man versteht beides nicht.
|
||||
|
||||
**Sprachdialog** — der Agent kann auch *zuhoeren* (`listen` / `listen_stop`) und
|
||||
streamt das Mikrofon mit demselben Protokoll wie die App
|
||||
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`).
|
||||
|
||||
> **Turn-Ownership:** der RVS ist ein Broadcast-Relay ohne Adressierung — jeder
|
||||
> Client im Raum bekommt jede Antwort. Ohne Filter wuerden Handy und Fire TV
|
||||
> dieselbe Antwort gleichzeitig vorlesen. Jedes Geraet gibt darum nur aus, was
|
||||
> aus *seiner* eigenen Frage entstanden ist (`audioRequestId` → `messageId`).
|
||||
|
||||
### Bauen + ausrollen
|
||||
|
||||
```bash
|
||||
cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
|
||||
# -> dist/aria-android-agent.apk
|
||||
|
||||
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
|
||||
cd host-agent && ./release_agent.sh 0.0.1.4
|
||||
```
|
||||
|
||||
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
|
||||
(gitignored). Ohne ihn bekaeme jeder Build einen neuen Debug-Key, und Android
|
||||
verweigert die Installation mit *„Konflikt mit einem bestehenden Paket"* statt
|
||||
zu aktualisieren.
|
||||
|
||||
### Ausrollen per Satellit (ohne Kabel, ohne Abtippen)
|
||||
|
||||
Ein Satellit im selben Netz kann Android-Geraete per **ADB over Network**
|
||||
bespielen — besonders fuer den Fire TV, wo QR-Code-Scannen nicht geht:
|
||||
|
||||
```
|
||||
Diagnostic → Satelliten → Geraet → „Agent ausrollen"
|
||||
```
|
||||
|
||||
Der Satellit verbindet per ADB (Port 5555, wartet auf die Bestaetigung am
|
||||
Geraet — ein Klick genuegt), installiert die APK, erteilt die Berechtigungen per
|
||||
`appops`/`pm grant` und **provisioniert die RVS-Zugangsdaten per Intent** — man
|
||||
tippt am Fernseher nichts ein. Ein schon vergebener Geraetename wird dabei
|
||||
vorgeschlagen; nur wenn es keinen gibt, kommt der automatisch generierte.
|
||||
|
||||
> Die Berechtigungen bleiben ueber Neustarts erhalten; die Bildschirm-Aufnahme
|
||||
> schaltet sich nach einem Reboot selbst wieder scharf.
|
||||
|
||||
---
|
||||
|
||||
## Docker Volumes
|
||||
@@ -1073,14 +1188,28 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
|
||||
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
|
||||
Die Diagnostic-Upload-UI ist Phase 2.
|
||||
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
|
||||
- **Audio-Format**: Die App streamt live 16 kHz mono s16le an die STT-Bridge
|
||||
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`). Der alte Weg
|
||||
(AAC/MP4 aufnehmen, Bridge konvertiert per FFmpeg) existiert noch als Fallback.
|
||||
- **Sprachaufnahme ist nicht wiederaufsetzbar**: Die Audio-Chunks gehen live
|
||||
raus. Bricht die Verbindung mitten in der Aufnahme ab, fehlt dieses Stueck —
|
||||
es wird nicht gepuffert und nicht nachgeschickt. Gleiches gilt fuer die
|
||||
Sprachausgabe: F5-TTS streamt saetzeweise PCM, ein Abbruch verliert den Rest.
|
||||
Lokal aufnehmen + wiederaufsetzbar hochladen (Queue mit Teil-Uebertragung)
|
||||
waere der naechste Schritt, ist aber noch nicht gebaut.
|
||||
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
|
||||
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
|
||||
- **Bildanalyse eingeschraenkt**: Bilder werden in `/shared/uploads/` gespeichert. ARIA kann
|
||||
sie per Bash/Read-Tool oeffnen, aber Claude Vision (direkte Bildanalyse) ist ueber den
|
||||
Proxy-Pfad (`claude --print`) noch nicht moeglich. ARIA sieht den Dateipfad, nicht das Bild.
|
||||
- **Dateigroesse**: Grosse Dateien (>5MB) koennen WebSocket-Limits ueberschreiten.
|
||||
Bilder werden in der App auf max 1920x1920px @ 80% Qualitaet komprimiert.
|
||||
App-seitig fragt die Aufnahme-Sperre den Socket statt den gemerkten Zustand.
|
||||
- **Dateigroesse**: Der RVS und alle Worker fahren `max_size` 1500 MB (≈1 GB
|
||||
binaer plus base64-Overhead); groessere Dateien weist die Bridge aktiv ab,
|
||||
statt die Verbindung reissen zu lassen. Die App holt ueber **25 MB** nichts
|
||||
mehr ungefragt nach und bremst beim ausdruecklichen Antippen bei **64 MB** —
|
||||
base64-in-JSON muss komplett in den Speicher, und ein 150-MB-Video hat das
|
||||
Handy per OOM umgebracht. Bilder werden in der App auf max 1920x1920px @ 80%
|
||||
Qualitaet komprimiert.
|
||||
- **Lautstarke Umgebung stoert die STT**: Laute Musik im Raum verschlechtert die
|
||||
Erkennung messbar. Bewusst akzeptiert — Wake-Wort-Bestaetigung und der
|
||||
Silero-Endpoint stehen als Opt-in bereit.
|
||||
|
||||
---
|
||||
|
||||
@@ -1190,6 +1319,47 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
|
||||
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
|
||||
|
||||
### Phase C — ARIA greift auf echte Geraete durch (09–10/2026)
|
||||
|
||||
- [x] **Host-Agenten** (Linux/Windows/macOS): Standalone-Binary, ausgehend zum RVS, steuert den Rechner auf dem es laeuft. Windows-Build per Docker+Wine samt `setup.exe` (NSIS + nssm) das einen Dienst installiert und seine `.env` aus ProgramData liest. Plus **GUI-Steuerung** capability-gated (`windows`/`macos`/`xdotool`/`ydotool`) — ein Server ohne X meldet die Faehigkeit einfach nicht
|
||||
- [x] **Android-Agent** (eine APK fuer Handy + Fire TV): sehen (Screenshot via MediaProjection, `ui_dump` via Accessibility), steuern (tap/text/swipe/key/app_launch, nur entsperrt), ausgeben (Overlay ueber dem laufenden Bild + ARIAs **echte** Stimme per `tts_request`, weil Fire TV Sticks oft keine deutsche TTS-Engine haben), Audio-Fokus (Film wird leiser/angehalten), Sprachdialog mit demselben STT-Protokoll wie die App. Fester Signaturschluessel, damit Builds als Update statt als Paket-Konflikt ankommen
|
||||
- [x] **Ausrollen per Satellit** (ADB over Network): APK installieren, Berechtigungen per `appops`/`pm grant` erteilen und die **RVS-Zugangsdaten per Intent provisionieren** — am Fire TV tippt man nichts ein, QR-Scannen geht dort nicht. Geraetename am PC vergeben, vorhandener Name wird vorgeschlagen
|
||||
- [x] **Agent-Historie** in der Diagnostic: Befehle + Screenshots pro Agent, gruppiert pro User-Nachricht, aufklappbar
|
||||
- [x] **Jellyfin-Integration**: mehrere Server mit API-Key, ARIA steuert die Wiedergabe ueber die API statt sich durch die Oberflaeche zu klicken; eingeschlafene Sitzungen baut sie selbst neu auf
|
||||
- [x] **Auslastungs-Monitor** pro Compute-Box: live `nvidia-smi` + Verlaufs-Graphen (GPU + Tokens) ueber RVS, Historie liegt auf der Box
|
||||
- [x] **OpenAI-Endpunkt fuers lokale LLM** (+ optionales `openhands`-Profil); `.env` gewinnt immer, Diagnostic-Felder dann nur lesend
|
||||
- [x] **Teilen-Ziel in der App** + **Sprachnachricht zu Anhaengen** + **Zwischenruf per Sprache**
|
||||
- [x] **Trigger mit Projekt-Kontext**: die Antwort kommt dort zurueck, wo der Timer gestellt wurde (vorher immer Hauptchat), und `trigger_list` unterscheidet „schon gefeuert" von „deaktiviert"
|
||||
|
||||
#### Jagd auf verlorene Sprachnachrichten (die lange Kette)
|
||||
|
||||
Ein hartnaeckiges „die erste Sprachnachricht kommt nicht durch" waren am Ende
|
||||
**fuenf unabhaengige Ursachen** — hier dokumentiert, weil jede einzelne fuer
|
||||
sich plausibel aussah:
|
||||
|
||||
- [x] **200-MB-Broadcast** — der RVS ist ein Broadcast-Relay: ein per
|
||||
`file_request` nachgeladenes 150-MB-Video ging als ~200 MB base64 an **alle**
|
||||
Clients. Die STT/TTS-Worker starben daran mit Code 1009 und nahmen die
|
||||
laufende Aufnahme mit; die App starb am OOM und wurde von Android neu
|
||||
gestartet → Auto-Re-Download → von vorn (Schleife alle ~70s). Fix dreifach:
|
||||
Worker-`max_size` an den RVS angeglichen, **Datei-Antworten gehen nur noch an
|
||||
den Fragenden** (requestId-Korrelation im RVS), und die App holt ueber 25 MB
|
||||
nichts mehr ungefragt
|
||||
- [x] **`spawn E2BIG`** im Proxy — der System-Prompt lief als Kommandozeilen-
|
||||
Argument gegen `ARG_MAX` (131072). Jetzt `--system-prompt-file`
|
||||
- [x] **RVS-Allow-List** verwarf fuenf Nachrichtentypen still (`stt_lease*`,
|
||||
`chat_ack`, `stt_transcribe_*`) — jede Aufnahme startete verspaetet, erste
|
||||
Silben weg
|
||||
- [x] **Whisper-Kaltstart** — Session wurde angelegt, bevor das Modell geladen
|
||||
war; Audio lief ins Leere
|
||||
- [x] **Zombie-Verbindung** in der App — zeigte „getrennt", empfing aber weiter;
|
||||
die Aufnahme-Sperre fragt jetzt den Socket statt den gemerkten Zustand
|
||||
- [x] **Lange Sprachnachrichten brachen mitten im Satz ab** — der Endpointer
|
||||
zaehlte seine **eigene** Rechenzeit als Stille: der 200ms-Tick wartete auf die
|
||||
Zwischen-Transkription, und die lief jedes Mal ueber den kompletten Buffer
|
||||
(bei zwei Minuten mehrere Sekunden). Jetzt laeuft sie nebenher und sieht nur
|
||||
die letzten 30s; der finale Text kommt unveraendert aus dem ganzen Buffer
|
||||
|
||||
### Phase 2 — ARIA wird produktiv
|
||||
|
||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, etc. — durch normale Anfragen)
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20504
|
||||
versionName "0.2.5.4"
|
||||
versionCode 20505
|
||||
versionName "0.2.5.5"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.4",
|
||||
"version": "0.2.5.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -217,6 +217,13 @@ function formatDur(sec: number): string {
|
||||
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
|
||||
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
|
||||
// Meldung statt eines Absturzes.
|
||||
/** Signatur von sendPendingAttachments — fuer den Ref weiter unten. */
|
||||
type SendAttachmentsFn = (
|
||||
messageText: string,
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => Promise<void>;
|
||||
|
||||
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
@@ -391,6 +398,16 @@ const ChatScreen: React.FC = () => {
|
||||
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
// Zwischenruf per Sprache: der 📣-Knopf ohne Text "spannt" die naechste
|
||||
// Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref
|
||||
// wird gelesen, wenn die Aufnahme startet — der State nur fuer die Optik.
|
||||
const [interjectArmed, setInterjectArmed] = useState(false);
|
||||
const interjectArmedRef = useRef(false);
|
||||
// sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter
|
||||
// oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone
|
||||
// greifen (TS2448 — siehe Kommentar an sendTextMessage). Umsortieren waere
|
||||
// riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen.
|
||||
const sendPendingAttachmentsRef = useRef<SendAttachmentsFn | null>(null);
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||
const [ttsMuted, setTtsMuted] = useState(false);
|
||||
@@ -1324,7 +1341,11 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
setMessages(prev => {
|
||||
const newText = `\uD83C\uDFA4 ${sttText}`;
|
||||
// Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014
|
||||
// sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in
|
||||
// den laufenden Turn gegangen ist und keine eigene Antwort bekommt.
|
||||
const isInterject = !!(message.payload as any).interject;
|
||||
const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`;
|
||||
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
|
||||
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
|
||||
// gesendet wurden und ihre STT-Results ueberlappen.
|
||||
@@ -1339,7 +1360,9 @@ const ChatScreen: React.FC = () => {
|
||||
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
|
||||
// nimmt die ERSTE noch unaufgeloeste Placeholder.
|
||||
const idx = prev.findIndex(m =>
|
||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
||||
m.sender === 'user'
|
||||
&& (m.text.includes('Spracheingabe wird verarbeitet')
|
||||
|| m.text.includes('Zwischenruf wird verarbeitet'))
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const next = prev.slice();
|
||||
@@ -2413,11 +2436,18 @@ const ChatScreen: React.FC = () => {
|
||||
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||
const interruptAriaIfBusy = useCallback(() => {
|
||||
// forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen
|
||||
// werden — das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst
|
||||
// redet ARIA in das offene Mikro hinein.
|
||||
const interruptAriaIfBusy = useCallback((forInterject = false) => {
|
||||
if (audioService.isPlayingAudio()) {
|
||||
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
return true;
|
||||
audioService.haltAllPlayback(forInterject
|
||||
? 'user ruft dazwischen — nur Wiedergabe stoppen'
|
||||
: 'user startet Aufnahme — Interrupt');
|
||||
if (!forInterject) {
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
}
|
||||
return !forInterject;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
@@ -2430,18 +2460,35 @@ const ChatScreen: React.FC = () => {
|
||||
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const asInterject = interjectArmedRef.current;
|
||||
const wasInterrupted = interruptAriaIfBusy(asInterject);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
// Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen
|
||||
// (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der
|
||||
// Satz dazu noch kommt. Die Bubble mit den Bildern macht
|
||||
// sendPendingAttachments — sie traegt die audioRequestId und bekommt den
|
||||
// gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble.
|
||||
let attachmentCmid = '';
|
||||
if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) {
|
||||
attachmentCmid = nextClientMsgId();
|
||||
const forVoice = pendingAttachments;
|
||||
setPendingAttachments([]);
|
||||
await sendPendingAttachmentsRef.current('', forVoice, {
|
||||
audioRequestId, clientMsgId: attachmentCmid,
|
||||
});
|
||||
} else {
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: asInterject ? '📣 Zwischenruf wird verarbeitet...'
|
||||
: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
}
|
||||
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
@@ -2456,15 +2503,37 @@ const ChatScreen: React.FC = () => {
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
...(attachmentCmid && { clientMsgId: attachmentCmid }),
|
||||
...(asInterject && { interject: true }),
|
||||
});
|
||||
if (!ok) {
|
||||
if (attachmentCmid) {
|
||||
// Die Anhaenge sind SCHON hochgeladen — die Bubble darf nicht weg,
|
||||
// sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem
|
||||
// Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den
|
||||
// Auftrag einfach tippen.
|
||||
setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId
|
||||
? { ...m, text: '📎 Anhang gesendet — Mikro war belegt, bitte tippen',
|
||||
audioRequestId: undefined }
|
||||
: m));
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Mikro nicht verfügbar — Anhang ist aber da', ToastAndroid.LONG);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||
return false;
|
||||
}
|
||||
// Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme.
|
||||
if (asInterject) {
|
||||
interjectArmedRef.current = false;
|
||||
setInterjectArmed(false);
|
||||
}
|
||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||
return true;
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup,
|
||||
pendingAttachments]);
|
||||
|
||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||
@@ -2570,6 +2639,11 @@ const ChatScreen: React.FC = () => {
|
||||
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
|
||||
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
// Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann
|
||||
// gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden),
|
||||
// aber ohne Text — den liefert spaeter der stt_endpoint, und die Bridge
|
||||
// merged beides ueber die clientMsgId zu einem Turn.
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => {
|
||||
const atts = explicitAttachments ?? pendingAttachments;
|
||||
if (atts.length === 0) return;
|
||||
@@ -2597,16 +2671,23 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
||||
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
||||
const cmid = messageText ? nextClientMsgId() : undefined;
|
||||
// Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am
|
||||
// stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt.
|
||||
const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined);
|
||||
const activePid = focusedProjectIdRef.current;
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${atts.length} Anhang/Anhaenge`,
|
||||
// Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text —
|
||||
// via audioRequestId-Match, genau wie eine normale Sprachnachricht. So
|
||||
// stehen Bild und Satz zusammen in einer Bubble statt in zwei.
|
||||
text: voiceMode ? '🎙 Spracheingabe wird verarbeitet...'
|
||||
: (messageText || `${atts.length} Anhang/Anhaenge`),
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
projectId: activePid,
|
||||
...(cmid && {
|
||||
...(voiceMode && { audioRequestId: voiceMode.audioRequestId }),
|
||||
...(cmid && !voiceMode && {
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
@@ -2644,6 +2725,9 @@ const ChatScreen: React.FC = () => {
|
||||
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
// awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans
|
||||
// Brain schicken, sondern auf die Sprachnachricht warten.
|
||||
...(voiceMode && { awaitVoice: true }),
|
||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||
...(location && { location }),
|
||||
});
|
||||
@@ -2651,8 +2735,8 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||
// adressieren.
|
||||
if (messageText && cmid) {
|
||||
// adressieren. Im Sprach-Modus gibt es hier noch keinen Text.
|
||||
if (messageText && cmid && !voiceMode) {
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text: messageText,
|
||||
voice: localXttsVoiceRef.current,
|
||||
@@ -2665,6 +2749,9 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments([]);
|
||||
setInputText('');
|
||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||
// Ref aktuell halten — handleVoiceButtonStart greift die Funktion von hier
|
||||
// (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef).
|
||||
sendPendingAttachmentsRef.current = sendPendingAttachments;
|
||||
|
||||
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||
@@ -3529,7 +3616,9 @@ const ChatScreen: React.FC = () => {
|
||||
style={styles.textInput}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
|
||||
placeholder={pendingAttachments.length > 0
|
||||
? "Text zu den Anhaengen — oder einsprechen 🎤"
|
||||
: "Nachricht an ARIA..."}
|
||||
placeholderTextColor="#555570"
|
||||
multiline
|
||||
maxLength={4000}
|
||||
@@ -3537,29 +3626,52 @@ const ChatScreen: React.FC = () => {
|
||||
returnKeyType="send"
|
||||
/>
|
||||
|
||||
{/* Senden oder Sprache */}
|
||||
{/* Rechte Knopfreihe.
|
||||
Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text.
|
||||
Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein
|
||||
Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen
|
||||
und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache.
|
||||
Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach
|
||||
Lage, damit die Zeile nicht ueberlaeuft. */}
|
||||
|
||||
{/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder
|
||||
noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher.
|
||||
Ohne Text: die naechste Aufnahme geht als Zwischenruf in den
|
||||
laufenden Turn, statt sich als neue Anfrage anzustellen. */}
|
||||
{interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
|
||||
onPress={() => {
|
||||
if (inputText.trim()) { sendInterject(); return; }
|
||||
const next = !interjectArmedRef.current;
|
||||
interjectArmedRef.current = next;
|
||||
setInterjectArmed(next);
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(
|
||||
next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus'
|
||||
: 'Zwischenruf abgew\u00E4hlt',
|
||||
ToastAndroid.SHORT);
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'}
|
||||
>
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||
<>
|
||||
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
||||
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
||||
sie anzustellen. */}
|
||||
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
|
||||
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
|
||||
{ttsDeviceEnabled && (
|
||||
@@ -4085,6 +4197,13 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
// Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller
|
||||
// plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach.
|
||||
interjectButtonArmed: {
|
||||
backgroundColor: '#FFB340',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFF',
|
||||
},
|
||||
interjectIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
|
||||
@@ -1141,6 +1141,13 @@ class AudioService {
|
||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||
projectId?: string;
|
||||
/** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung.
|
||||
* Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn
|
||||
* zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */
|
||||
clientMsgId?: string;
|
||||
/** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue
|
||||
* Anfrage in die Warteschlange (Sprach-Gegenstueck zum 📣-Knopf). */
|
||||
interject?: boolean;
|
||||
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||
@@ -1245,6 +1252,8 @@ class AudioService {
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
targetInstance: this.streamTargetInstance,
|
||||
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
|
||||
...(opts.interject ? { interject: true } : {}),
|
||||
});
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
|
||||
+135
-20
@@ -820,13 +820,13 @@ META_TOOLS = [
|
||||
"function": {
|
||||
"name": "oauth_get_token",
|
||||
"description": (
|
||||
"Liefert das aktuelle access_token fuer einen Service. "
|
||||
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
|
||||
"und der Provider einen refresh_token mitgegeben hat.\n\n"
|
||||
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
|
||||
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
|
||||
"z.B. `Authorization: Bearer <token>`.\n\n"
|
||||
"Wirft wenn Service noch nicht authentifiziert ist oder der "
|
||||
"Prueft, ob ein Service authentifiziert ist, und wie lange der "
|
||||
"Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
|
||||
"GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
|
||||
"fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
|
||||
"Authorization selbst an. In einem Skill holst Du ihn zur "
|
||||
"Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
|
||||
"Wirft, wenn der Service noch nicht authentifiziert ist oder der "
|
||||
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
|
||||
),
|
||||
"parameters": {
|
||||
@@ -838,6 +838,35 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "oauth_call",
|
||||
"description": (
|
||||
"Ruft eine Provider-API MIT dem hinterlegten Zugangstoken auf, "
|
||||
"ohne dass Du den Token siehst. Das Brain haengt den "
|
||||
"Authorization-Header an, fuehrt den Request aus und gibt Dir nur "
|
||||
"die Antwort.\n\n"
|
||||
"Nimm das fuer einzelne Aufrufe (z.B. Spotify-Wiedergabe steuern, "
|
||||
"GitHub-Issue anlegen). Fuer etwas, das Du oefter brauchst, bau "
|
||||
"lieber einen Skill.\n\n"
|
||||
"Nur https, und nur Hosts, die zum Service gehoeren — eine fremde "
|
||||
"Domain wird abgelehnt (sonst koennte der Token irgendwohin "
|
||||
"geschickt werden). Antwort wird bei 200 KB gekuerzt."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {"type": "string", "description": "z.B. spotify, github."},
|
||||
"url": {"type": "string", "description": "Vollstaendige https-URL der API."},
|
||||
"method": {"type": "string", "description": "GET (Default), POST, PUT, PATCH, DELETE."},
|
||||
"body": {"type": "string", "description": "Request-Body, meist JSON als String."},
|
||||
"content_type": {"type": "string", "description": "Default application/json."},
|
||||
},
|
||||
"required": ["service", "url"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1599,7 +1628,9 @@ META_TOOLS = [
|
||||
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
|
||||
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
|
||||
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
|
||||
"Berechtigung 'Ueber anderen Apps anzeigen'."
|
||||
"Berechtigung 'Ueber anderen Apps anzeigen'. Mit image_path kann "
|
||||
"ein BILD mit Text darueber eingeblendet werden (z.B. ein "
|
||||
"generiertes Bild oder ein Screenshot) — nur bei Android-Agenten."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -1610,6 +1641,14 @@ META_TOOLS = [
|
||||
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
|
||||
"mode": {"type": "string", "description": "overlay | speech | both"},
|
||||
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
|
||||
"image_path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional: Pfad zu einem Bild (PNG/JPEG) unter "
|
||||
"/shared/uploads/, das MIT dem Text eingeblendet wird. "
|
||||
"Nur Android-Agenten koennen das."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["host", "text"],
|
||||
},
|
||||
@@ -3246,8 +3285,31 @@ class Agent:
|
||||
if name in _UI_ACTIONS:
|
||||
action, keys = _UI_ACTIONS[name]
|
||||
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
|
||||
# Bild-Einblendung: ARIA gibt einen Pfad an (damit arbeitet sie
|
||||
# sowieso), der Agent will base64. Hier umwandeln, statt ARIA
|
||||
# megabyteweise base64 durch den Tool-Call schieben zu lassen.
|
||||
img_path = (arguments.get("image_path") or "").strip()
|
||||
if action == "notify" and img_path:
|
||||
if not img_path.startswith("/shared/uploads/"):
|
||||
return "FEHLER: image_path muss unter /shared/uploads/ liegen."
|
||||
if not os.path.isfile(img_path):
|
||||
return f"FEHLER: Bild nicht gefunden: {img_path}"
|
||||
# Overlay-Bilder ueber ~4 MB sind sinnlos gross fuer einen
|
||||
# Fernseher und blaehen die WS-Nachricht unnoetig auf.
|
||||
_img_size = os.path.getsize(img_path)
|
||||
if _img_size > 4 * 1024 * 1024:
|
||||
return (f"FEHLER: Bild zu gross fuer eine Einblendung "
|
||||
f"({_img_size // (1024 * 1024)} MB > 4 MB). "
|
||||
f"Vorher verkleinern.")
|
||||
with open(img_path, "rb") as _f:
|
||||
params["image_base64"] = _b64.b64encode(_f.read()).decode("ascii")
|
||||
# Sprachausgabe braucht laenger: der Agent meldet erst Erfolg,
|
||||
# wenn wirklich Ton laeuft (TTS-Render + Uebertragung). Mit den
|
||||
# 20s der UI-Aktionen lief die ehrliche Antwort in den Timeout.
|
||||
_timeout = 40 if action in ("speak", "notify") else 20
|
||||
result = _post("/internal/host",
|
||||
{"host": host, "action": action, "params": params}, 20)
|
||||
{"host": host, "action": action, "params": params},
|
||||
_timeout)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
@@ -3512,6 +3574,10 @@ class Agent:
|
||||
fires_at_iso=fires_at_iso,
|
||||
message=arguments["message"],
|
||||
author="aria",
|
||||
# Projekt DIESES Requests mitnehmen: beim Feuern soll die
|
||||
# Antwort dort landen, wo der Timer gestellt wurde — nicht
|
||||
# im Hauptchat.
|
||||
project_id=project_id or "",
|
||||
)
|
||||
self._pending_events.append({
|
||||
"type": "trigger_created",
|
||||
@@ -3527,6 +3593,7 @@ class Agent:
|
||||
check_interval_sec=int(arguments.get("check_interval_sec", 300)),
|
||||
throttle_sec=int(arguments.get("throttle_sec", 3600)),
|
||||
author="aria",
|
||||
project_id=project_id or "",
|
||||
)
|
||||
self._pending_events.append({
|
||||
"type": "trigger_created",
|
||||
@@ -3599,13 +3666,29 @@ class Agent:
|
||||
return "(keine Trigger vorhanden)"
|
||||
lines = []
|
||||
for t in items:
|
||||
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
|
||||
if t["type"] == "timer":
|
||||
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
|
||||
elif t["type"] == "watcher":
|
||||
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
|
||||
# WICHTIG: ein One-Shot-Timer wird NACH dem Feuern automatisch
|
||||
# deaktiviert. Stand hier nur "DEAKTIVIERT", sahen "hat sauber
|
||||
# gefeuert" und "war nie an" identisch aus — und genau das hat
|
||||
# schon zu einer falschen Auskunft gefuehrt ("ich habe
|
||||
# vergessen ihn zu aktivieren", obwohl er gefeuert hatte).
|
||||
# Darum: gefeuert IMMER explizit mit Zeitpunkt und Zaehler.
|
||||
fired_n = int(t.get("fire_count", 0) or 0)
|
||||
last_fired = t.get("last_fired_at") or ""
|
||||
if fired_n > 0:
|
||||
state = (f"SCHON GEFEUERT ({fired_n}x, zuletzt {last_fired})"
|
||||
+ ("" if t.get("active", True) else ", danach automatisch deaktiviert"))
|
||||
elif t.get("active", True):
|
||||
state = "aktiv, noch nicht gefeuert"
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||
state = "DEAKTIVIERT, nie gefeuert"
|
||||
# Wohin die Antwort beim Feuern geht — leer = Hauptchat.
|
||||
where = f" [Kontext: {t.get('project_id') or 'Hauptchat'}]"
|
||||
if t["type"] == "timer":
|
||||
lines.append(f"- {t['name']} (timer, {state}){where}: feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
|
||||
elif t["type"] == "watcher":
|
||||
lines.append(f"- {t['name']} (watcher, {state}){where}: cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state}){where}")
|
||||
return "\n".join(lines)
|
||||
if name == "oauth_register_provider":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
@@ -3669,15 +3752,47 @@ class Agent:
|
||||
record = oauth_mod.get_token(svc)
|
||||
except RuntimeError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
tok = record.get("access_token", "")
|
||||
ttype = record.get("token_type", "Bearer")
|
||||
exp = record.get("expires_at", 0)
|
||||
remain = max(0, int(exp) - int(__import__("time").time()))
|
||||
hosts = ", ".join(oauth_mod.allowed_hosts(svc)) or "(keine hinterlegt)"
|
||||
# Der Token wird hier BEWUSST nicht mehr herausgegeben. Frueher
|
||||
# stand er im Klartext in dieser Tool-Antwort und damit im
|
||||
# Gespraechsprotokoll — also auch bei Anthropic. Fuer API-Aufrufe
|
||||
# gibt es oauth_call: das Brain haengt den Header selbst an.
|
||||
return (
|
||||
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
|
||||
f"access_token: {tok}\n"
|
||||
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
|
||||
f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
|
||||
f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
|
||||
f"- Einzelne API-Aufrufe: `oauth_call(service='{svc}', url=...)` — "
|
||||
f"das Brain haengt die Authorization an.\n"
|
||||
f"- In einem Skill: GET {{BRAIN_INTERNAL_URL}}/oauth/{svc}/token "
|
||||
f"zur Laufzeit holen, nie hartkodieren.\n"
|
||||
f"Erlaubte Hosts: {hosts}"
|
||||
)
|
||||
if name == "oauth_call":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
url = (arguments.get("url") or "").strip()
|
||||
if not svc or not url:
|
||||
return "FEHLER: service und url sind Pflicht."
|
||||
body = arguments.get("body")
|
||||
if body is not None and not isinstance(body, str):
|
||||
body = json.dumps(body, ensure_ascii=False)
|
||||
try:
|
||||
status, text = oauth_mod.call(
|
||||
svc, url,
|
||||
method=(arguments.get("method") or "GET"),
|
||||
body=body,
|
||||
content_type=(arguments.get("content_type") or "application/json"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
except RuntimeError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
except Exception as exc:
|
||||
logger.exception("oauth_call fehlgeschlagen")
|
||||
return f"FEHLER: Aufruf fehlgeschlagen: {exc}"
|
||||
ok = 200 <= status < 300
|
||||
head = f"HTTP {status}" + ("" if ok else " (Fehler)")
|
||||
return f"{head}\n{text}" if text else head
|
||||
if name == "oauth_revoke":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
if not svc:
|
||||
|
||||
@@ -36,7 +36,8 @@ TICK_SEC = 8
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
|
||||
|
||||
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
|
||||
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list,
|
||||
project_id: str = "") -> None:
|
||||
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
|
||||
|
||||
Synchron via urllib — wird per run_in_executor aus dem async-Loop
|
||||
@@ -47,6 +48,10 @@ def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) ->
|
||||
"trigger_name": trigger_name,
|
||||
"type": ttype,
|
||||
"events": events or [],
|
||||
# Kontext, in dem der Trigger angelegt wurde. Leer = Hauptchat. Ohne das
|
||||
# landete JEDE Trigger-Antwort im Hauptchat, auch wenn der Timer mitten
|
||||
# in einem Projekt-Thread gestellt wurde.
|
||||
"projectId": project_id or "",
|
||||
}).encode("utf-8")
|
||||
url = f"{BRIDGE_URL}/internal/trigger-fired"
|
||||
try:
|
||||
@@ -156,17 +161,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
||||
# Executor auslagern, damit der Event-Loop frei bleibt.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Projekt, in dem der Trigger angelegt wurde — der Turn laeuft darin, und
|
||||
# die Antwort geht auch dorthin zurueck (statt immer in den Hauptchat).
|
||||
trigger_pid = str(trigger.get("project_id") or "")
|
||||
|
||||
def _run_turn():
|
||||
a = agent_factory()
|
||||
rep, *_rest = a.chat(prompt, source="trigger")
|
||||
rep, *_rest = a.chat(prompt, source="trigger", project_id=trigger_pid)
|
||||
return rep, a.pop_events()
|
||||
|
||||
reply, events = await loop.run_in_executor(None, _run_turn)
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
logger.info("[trigger] %s gefeuert (project=%s) → ARIA-Reply: %s",
|
||||
name, trigger_pid or "(main)", reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
||||
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype,
|
||||
events, trigger_pid)
|
||||
except Exception as e:
|
||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
|
||||
|
||||
@@ -398,6 +398,19 @@ def memory_delete(point_id: str):
|
||||
|
||||
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
|
||||
|
||||
@app.get("/attachments/limit")
|
||||
def memory_attachments_limit():
|
||||
"""Aktuelles Groessen-Limit fuer Memory-Anhaenge.
|
||||
|
||||
Die Oberflaechen zeigen damit die ECHTE Zahl an statt eines fest
|
||||
eingetippten "max 20 MB" — das stand vorher im HTML und stimmte nicht mehr,
|
||||
sobald jemand das Limit anhob.
|
||||
"""
|
||||
import memory_attachments as mem_att
|
||||
limit = mem_att.max_bytes()
|
||||
return {"maxBytes": limit, "maxMb": round(limit / 1024 / 1024, 1)}
|
||||
|
||||
|
||||
@app.get("/memory/{point_id}/attachments")
|
||||
def memory_attachments_list(point_id: str):
|
||||
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
|
||||
|
||||
@@ -16,6 +16,7 @@ selbst hat in Stufe A kein Tool zum Upload.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -27,9 +28,41 @@ from typing import List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
|
||||
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
|
||||
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||
|
||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||
# 64 MB: so viel holt die App auch auf ausdruecklichen Tipp noch herunter
|
||||
# (TAP_DL_MAX_BYTES in ChatScreen.tsx). Ein hoeheres Limit wuerde Anhaenge
|
||||
# erlauben, die das Handy anschliessend nicht mehr laden kann.
|
||||
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def max_bytes() -> int:
|
||||
"""Aktuelles Groessen-Limit fuer Anhaenge.
|
||||
|
||||
Reihenfolge: runtime.json (Diagnostic → System & Wartung) > ENV > Default.
|
||||
Wird bei JEDEM Aufruf gelesen, damit eine Aenderung in der Diagnostic sofort
|
||||
greift — ein Brain-Neustart nur fuers Anheben einer Zahl waere Unsinn.
|
||||
"""
|
||||
try:
|
||||
with RUNTIME_CONFIG_FILE.open() as f:
|
||||
mb = json.load(f).get("memoryAttachmentMaxMb")
|
||||
if mb is not None:
|
||||
val = int(float(mb) * 1024 * 1024)
|
||||
if val > 0:
|
||||
return val
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
|
||||
env = os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES")
|
||||
if env:
|
||||
try:
|
||||
return int(env)
|
||||
except ValueError:
|
||||
logger.warning("MEMORY_ATTACHMENT_MAX_BYTES ist keine Zahl: %r", env)
|
||||
return _DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Macht aus einem User-Namen einen filesystem-sicheren String —
|
||||
@@ -78,8 +111,12 @@ def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
|
||||
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
|
||||
if not memory_id:
|
||||
raise ValueError("memory_id ist Pflicht")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
|
||||
limit = max_bytes()
|
||||
if len(data) > limit:
|
||||
raise ValueError(
|
||||
f"Anhang zu gross ({len(data) // (1024 * 1024)} MB > "
|
||||
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
||||
f"unter System & Wartung.")
|
||||
safe = _safe_filename(filename)
|
||||
d = memory_dir(memory_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -165,8 +202,12 @@ def attach_from_path(memory_id: str, source_path: str) -> dict:
|
||||
if not src.is_file():
|
||||
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
||||
size = src.stat().st_size
|
||||
if size > MAX_BYTES:
|
||||
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
|
||||
limit = max_bytes()
|
||||
if size > limit:
|
||||
raise ValueError(
|
||||
f"Datei zu gross ({size // (1024 * 1024)} MB > "
|
||||
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
||||
f"unter System & Wartung.")
|
||||
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
||||
data = src.read_bytes()
|
||||
return save_attachment(memory_id, src.name, data)
|
||||
|
||||
+111
-1
@@ -28,6 +28,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str,
|
||||
scopes: Optional[list[str]] = None,
|
||||
client_auth: str = "body",
|
||||
extra_auth_params: Optional[dict] = None,
|
||||
accept_header: Optional[str] = None) -> dict:
|
||||
accept_header: Optional[str] = None,
|
||||
api_hosts: Optional[list[str]] = None) -> dict:
|
||||
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
|
||||
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
|
||||
vom Brain-Tool `oauth_register_provider` gerufen.
|
||||
@@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str,
|
||||
entry["extra_auth_params"] = extra_auth_params
|
||||
if accept_header is not None:
|
||||
entry["accept_header"] = accept_header
|
||||
if api_hosts is not None:
|
||||
# Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf.
|
||||
# Noetig, wenn die API auf einer anderen Domain liegt als der Login
|
||||
# (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber
|
||||
# manche Provider nutzen voellig andere Namen).
|
||||
entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()]
|
||||
apps[svc] = entry
|
||||
_save_json(APPS_FILE, apps)
|
||||
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
||||
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def allowed_hosts(service: str) -> list[str]:
|
||||
"""Hosts, an die ein Token dieses Services geschickt werden darf.
|
||||
|
||||
SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den
|
||||
Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt
|
||||
schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst
|
||||
gehoert: die Hosts aus auth_url/token_url plus optional eingetragene
|
||||
api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit
|
||||
(accounts.spotify.com -> api.spotify.com), fremde Domains nie.
|
||||
"""
|
||||
cfg = _provider_config(service)
|
||||
hosts: set[str] = set()
|
||||
for key in ("auth_url", "token_url"):
|
||||
u = cfg.get(key) or ""
|
||||
try:
|
||||
h = urllib.parse.urlparse(u).hostname or ""
|
||||
except Exception:
|
||||
h = ""
|
||||
if h:
|
||||
hosts.add(h.lower())
|
||||
for h in (cfg.get("api_hosts") or []):
|
||||
h = str(h).strip().lower()
|
||||
if h:
|
||||
hosts.add(h)
|
||||
return sorted(hosts)
|
||||
|
||||
|
||||
def _host_allowed(host: str, allowed: list[str]) -> bool:
|
||||
"""True, wenn host zu einem der erlaubten Hosts gehoert.
|
||||
|
||||
Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner
|
||||
endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf
|
||||
'spotify.com'.
|
||||
"""
|
||||
h = (host or "").lower().strip(".")
|
||||
if not h:
|
||||
return False
|
||||
|
||||
def base(x: str) -> str:
|
||||
parts = x.lower().strip(".").split(".")
|
||||
return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower()
|
||||
|
||||
hb = base(h)
|
||||
return any(h == a or hb == base(a) for a in allowed)
|
||||
|
||||
|
||||
def call(service: str, url: str, method: str = "GET",
|
||||
body: Optional[str] = None, content_type: str = "application/json",
|
||||
timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]:
|
||||
"""Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben.
|
||||
|
||||
Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token
|
||||
verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem
|
||||
Gespraechsprotokoll landen.
|
||||
|
||||
Returns (status_code, text). Wirft ValueError bei unerlaubter URL.
|
||||
"""
|
||||
svc = (service or "").strip()
|
||||
if not svc:
|
||||
raise ValueError("service ist Pflicht")
|
||||
u = (url or "").strip()
|
||||
if not u.startswith("https://"):
|
||||
# Kein http:// — der Token ginge im Klartext ueber die Leitung.
|
||||
raise ValueError("url muss mit https:// beginnen")
|
||||
host = urllib.parse.urlparse(u).hostname or ""
|
||||
allowed = allowed_hosts(svc)
|
||||
if not allowed:
|
||||
raise ValueError(
|
||||
f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.")
|
||||
if not _host_allowed(host, allowed):
|
||||
raise ValueError(
|
||||
f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}")
|
||||
|
||||
m = (method or "GET").upper()
|
||||
if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
raise ValueError(f"Methode nicht erlaubt: {method}")
|
||||
|
||||
record = get_token(svc) # refresht bei Bedarf
|
||||
token = record.get("access_token", "")
|
||||
ttype = record.get("token_type") or "Bearer"
|
||||
|
||||
data = body.encode("utf-8") if body else None
|
||||
req = urllib.request.Request(u, data=data, method=m)
|
||||
req.add_header("Authorization", f"{ttype} {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read(max_bytes + 1)
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b""
|
||||
status = exc.code
|
||||
truncated = len(raw) > max_bytes
|
||||
text = raw[:max_bytes].decode("utf-8", "replace")
|
||||
if truncated:
|
||||
text += "\n…(gekuerzt)"
|
||||
logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw))
|
||||
return status, text
|
||||
|
||||
|
||||
def list_services() -> list[dict]:
|
||||
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
||||
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
||||
|
||||
+15
-1
@@ -457,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None,
|
||||
"",
|
||||
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
|
||||
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
|
||||
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
|
||||
"1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.",
|
||||
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
||||
"3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt "
|
||||
"die Authorization selbst an.",
|
||||
"",
|
||||
"**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** "
|
||||
"Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag "
|
||||
"also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs"
|
||||
"variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill "
|
||||
"— der holt sich den Token zur Laufzeit ueber "
|
||||
"`{BRAIN_INTERNAL_URL}/oauth/<service>/token`, serverseitig.",
|
||||
"",
|
||||
"**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen "
|
||||
"API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. "
|
||||
"Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. "
|
||||
"Und wiederhole ein Secret niemals in Deiner Antwort.",
|
||||
"",
|
||||
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
||||
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
||||
|
||||
@@ -185,11 +185,21 @@ SEED_RULES: List[dict] = [
|
||||
"category": "skills",
|
||||
"content": (
|
||||
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
|
||||
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
|
||||
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
|
||||
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
|
||||
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
|
||||
"manuell neu einloggen, das nervt."
|
||||
"client_secret direkt in den Skill-Code — und schreibe sie auch "
|
||||
"NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
|
||||
"uebertragen und gespeichert.\n"
|
||||
"\n"
|
||||
"Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n"
|
||||
"- Im Gespraech: `oauth_call('<service>', url, ...)` — das Brain "
|
||||
"haengt die Authorization an, Du siehst den Token nie.\n"
|
||||
"- Im Skill-Code: den Token zur Laufzeit holen ueber "
|
||||
"GET {BRAIN_INTERNAL_URL}/oauth/<service>/token. Der Aufruf macht "
|
||||
"Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu "
|
||||
"einloggen, das nervt.\n"
|
||||
"- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, "
|
||||
"API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht "
|
||||
"in den Code und nicht in den Chat. Der Skill bekommt sie zur "
|
||||
"Laufzeit als CFG_<NAME>-Umgebungsvariable."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -235,9 +245,11 @@ SEED_RULES: List[dict] = [
|
||||
"content": (
|
||||
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
|
||||
"'token expired' zurueckgibt: RUFE ZUERST "
|
||||
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
|
||||
"gueltigen Token oder refresht ihn automatisch ueber den "
|
||||
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
|
||||
"`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
|
||||
"gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
|
||||
"ist (den Token selbst bekommst Du bewusst nicht — "
|
||||
"`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: "
|
||||
"danach denselben Aufruf einfach nochmal.\n"
|
||||
"\n"
|
||||
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
|
||||
"('refresh failed', 'no refresh_token', 'service nicht "
|
||||
|
||||
+12
-2
@@ -146,6 +146,7 @@ def create_timer(
|
||||
fires_at_iso: str,
|
||||
message: str,
|
||||
author: str = "aria",
|
||||
project_id: str = "",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
@@ -164,11 +165,16 @@ def create_timer(
|
||||
"created_at": _now_iso(),
|
||||
"fires_at": fires_at_iso,
|
||||
"message": message,
|
||||
# Projekt, in dem der Trigger angelegt wurde. Ohne das landete die
|
||||
# Antwort beim Feuern IMMER im Hauptchat — auch wenn der Timer mitten
|
||||
# in einem Projekt-Thread gestellt wurde. Leer = Hauptchat.
|
||||
"project_id": project_id or "",
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
|
||||
logger.info("Trigger angelegt: %s (timer, fires_at=%s, project=%s)",
|
||||
name, fires_at_iso, project_id or "(main)")
|
||||
return data
|
||||
|
||||
|
||||
@@ -179,6 +185,7 @@ def create_watcher(
|
||||
check_interval_sec: int = 300,
|
||||
throttle_sec: int = 3600,
|
||||
author: str = "aria",
|
||||
project_id: str = "",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
@@ -200,12 +207,15 @@ def create_watcher(
|
||||
"check_interval_sec": int(check_interval_sec),
|
||||
"throttle_sec": int(throttle_sec),
|
||||
"message": message,
|
||||
# Siehe create_timer: ohne Projekt landet die Antwort im Hauptchat.
|
||||
"project_id": project_id or "",
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
"last_checked_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
|
||||
logger.info("Trigger angelegt: %s (watcher, cond='%s', project=%s)",
|
||||
name, condition, project_id or "(main)")
|
||||
return data
|
||||
|
||||
|
||||
|
||||
+119
-16
@@ -739,6 +739,14 @@ class ARIABridge:
|
||||
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
||||
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
||||
self._stt_stream_projects: dict[str, str] = {}
|
||||
# Zusatz-Absicht pro Stream, auch von stt_stream_start:
|
||||
# cmid — clientMsgId der Anhaenge, die zu DIESER Aufnahme gehoeren
|
||||
# (Sprachnachricht als Beschreibung zu einem Bild/Video).
|
||||
# interject — die Aufnahme ist ein Zwischenruf in den laufenden Turn,
|
||||
# keine neue Anfrage.
|
||||
# Getrennt von _stt_stream_projects gehalten: der Fokus-Default greift
|
||||
# auch fuer alte App-Versionen, die diese Felder nicht mitschicken.
|
||||
self._stt_stream_meta: dict[str, dict] = {}
|
||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||
@@ -808,6 +816,18 @@ class ARIABridge:
|
||||
# Projekt landen statt im Hauptchat.
|
||||
self._pending_files_project_id: str = ""
|
||||
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
||||
# Wartet ein Upload auf eine SPRACHNACHRICHT als Beschreibung, reichen
|
||||
# 0.8s nicht: der Nutzer spricht erst, und Whisper braucht danach noch
|
||||
# ein paar Sekunden fuers Final-Transkript. Mit dem kurzen Fenster waere
|
||||
# der Anhang laengst alleine rausgegangen ("warte auf seine Anweisung"),
|
||||
# und der gesprochene Text kaeme als zweiter, unverbundener Turn.
|
||||
# Das lange Fenster ist nur eine Notbremse gegen Haengenbleiben, falls
|
||||
# gar kein Endpoint mehr kommt — der Normalfall ist der Merge im
|
||||
# stt_endpoint-Handler. Es MUSS ueber dem Aufnahme-Hard-Cap der App
|
||||
# liegen (Default 5 Minuten) plus Whisper-Final, sonst waeren die
|
||||
# Anhaenge bei einer langen Aufnahme vorher alleine rausgegangen.
|
||||
self._PENDING_FILES_VOICE_WINDOW_SEC: float = 420.0
|
||||
self._pending_files_await_voice: bool = False
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialisiert alle Komponenten.
|
||||
@@ -1879,6 +1899,13 @@ class ARIABridge:
|
||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||
|
||||
def _pending_files_window(self) -> float:
|
||||
"""Wie lange auf den zugehoerigen Text gewartet wird, bevor die Anhaenge
|
||||
alleine rausgehen. Lang, wenn eine Sprachnachricht unterwegs ist."""
|
||||
return (self._PENDING_FILES_VOICE_WINDOW_SEC
|
||||
if self._pending_files_await_voice
|
||||
else self._PENDING_FILES_WINDOW_SEC)
|
||||
|
||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
@@ -1892,13 +1919,15 @@ class ARIABridge:
|
||||
text = self._build_pending_files_message("", files)
|
||||
self._pending_files = []
|
||||
self._pending_files_flush_task = None
|
||||
self._pending_files_await_voice = False
|
||||
pid = self._pending_files_project_id
|
||||
self._pending_files_project_id = ""
|
||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||
|
||||
async def _flush_pending_files_with_text(self, user_text: str,
|
||||
project_id: str = "",
|
||||
client_msg_id: str = "") -> bool:
|
||||
client_msg_id: str = "",
|
||||
require_match: bool = False) -> bool:
|
||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||
@@ -1913,12 +1942,20 @@ class ARIABridge:
|
||||
|
||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||
|
||||
require_match: nur mergen, wenn die cmid wirklich passt. Der Sprach-Pfad
|
||||
nutzt das — sonst wuerde eine Sprachnachricht, die zufaellig waehrend
|
||||
eines fremden Uploads fertig wird, dessen Anhaenge an sich reissen. Beim
|
||||
Text-Pfad bleibt der Fallback (alles mergen) wie bisher, damit nie ein
|
||||
Bild verloren geht.
|
||||
"""
|
||||
if not self._pending_files:
|
||||
return False
|
||||
cmid = (client_msg_id or "").strip()
|
||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||
if not matching:
|
||||
if require_match:
|
||||
return False
|
||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||
matching = list(self._pending_files)
|
||||
remaining: list = []
|
||||
@@ -1935,10 +1972,11 @@ class ARIABridge:
|
||||
self._pending_files_flush_task = None
|
||||
if remaining:
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
else:
|
||||
self._pending_files_project_id = ""
|
||||
self._pending_files_await_voice = False
|
||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||
# fertig ist (siehe chat-handler oben).
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||
@@ -2752,10 +2790,17 @@ class ARIABridge:
|
||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||
file_cmid = str(payload.get("clientMsgId") or "")
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||
# awaitVoice: der Nutzer spricht gerade die Beschreibung zu diesem
|
||||
# Anhang ein. Dann NICHT nach 0.8s alleine rausschicken, sondern auf
|
||||
# den stt_endpoint warten (siehe _PENDING_FILES_VOICE_WINDOW_SEC).
|
||||
if payload.get("awaitVoice"):
|
||||
self._pending_files_await_voice = True
|
||||
logger.info("[rvs] Anhang wartet auf Sprachnachricht: %s (cmid=%s)",
|
||||
file_name, file_cmid[:12] or "?")
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -3324,8 +3369,18 @@ class ARIABridge:
|
||||
focused_pid = str(payload.get("projectId") or "")
|
||||
if req_id:
|
||||
self._stt_stream_projects[req_id] = focused_pid
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s",
|
||||
req_id[:12], focused_pid or "(main)")
|
||||
# Absicht dieser Aufnahme: gehoert sie zu wartenden Anhaengen,
|
||||
# und ist sie ein Zwischenruf statt einer neuen Anfrage?
|
||||
att_cmid = str(payload.get("clientMsgId") or "")
|
||||
is_interject = bool(payload.get("interject"))
|
||||
if att_cmid or is_interject:
|
||||
self._stt_stream_meta[req_id] = {
|
||||
"cmid": att_cmid, "interject": is_interject,
|
||||
}
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s%s%s",
|
||||
req_id[:12], focused_pid or "(main)",
|
||||
f" anhang-cmid={att_cmid[:12]}" if att_cmid else "",
|
||||
" [ZWISCHENRUF]" if is_interject else "")
|
||||
return
|
||||
|
||||
elif msg_type == "stt_stream_end":
|
||||
@@ -3340,10 +3395,10 @@ class ARIABridge:
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_later(
|
||||
20.0,
|
||||
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
|
||||
)
|
||||
def _drop(rid: str = req_id) -> None:
|
||||
self._stt_stream_projects.pop(rid, None)
|
||||
self._stt_stream_meta.pop(rid, None)
|
||||
loop.call_later(20.0, _drop)
|
||||
except Exception:
|
||||
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
||||
# dann lieber gar nicht aufraeumen als crashen.
|
||||
@@ -3427,11 +3482,36 @@ class ARIABridge:
|
||||
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||
stream_req_id = payload.get("requestId", "") or ""
|
||||
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
||||
meta = self._stt_stream_meta.pop(stream_req_id, {})
|
||||
|
||||
# Zwischenruf per Sprache: in den LAUFENDEN Turn schieben, nicht als
|
||||
# neue Anfrage anstellen. Gleicher Weg wie der 📣-Knopf mit Text.
|
||||
if meta.get("interject"):
|
||||
logger.info("[rvs] stt_endpoint ist ein Zwischenruf → project=%s: %s",
|
||||
focused_pid or "(main)", text[:80])
|
||||
asyncio.create_task(
|
||||
self._interject_proxy_for_project(focused_pid, text))
|
||||
# Den erkannten Text trotzdem broadcasten, damit die
|
||||
# Sprach-Bubble in der App ihren Text bekommt statt ewig auf
|
||||
# "wird verarbeitet..." zu stehen.
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": {
|
||||
"text": text,
|
||||
"sender": "stt",
|
||||
"audioRequestId": audio_request_id,
|
||||
"projectId": focused_pid,
|
||||
"interject": True,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
}))
|
||||
return
|
||||
|
||||
asyncio.create_task(self._process_endpoint_text(
|
||||
text, interrupted, audio_request_id, location,
|
||||
client_msg_id=client_msg_id,
|
||||
focused_project_id=focused_pid))
|
||||
focused_project_id=focused_pid,
|
||||
attachment_client_msg_id=str(meta.get("cmid") or "")))
|
||||
return
|
||||
|
||||
elif msg_type == "oauth_callback":
|
||||
@@ -3891,7 +3971,8 @@ class ARIABridge:
|
||||
audio_request_id: str = "",
|
||||
location: Optional[dict] = None,
|
||||
client_msg_id: Optional[str] = None,
|
||||
focused_project_id: str = "") -> None:
|
||||
focused_project_id: str = "",
|
||||
attachment_client_msg_id: str = "") -> None:
|
||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||
|
||||
@@ -3960,6 +4041,18 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||
|
||||
# Sprachnachricht als Beschreibung zu Anhaengen: die liegen schon
|
||||
# gepuffert (awaitVoice) und warten genau auf diesen Text — beides geht
|
||||
# als EIN Turn ans Brain, sonst sieht ARIA ein Bild ohne Auftrag und
|
||||
# danach einen Satz ohne Bild.
|
||||
if attachment_client_msg_id and await self._flush_pending_files_with_text(
|
||||
cleaned, project_id=project_id,
|
||||
client_msg_id=attachment_client_msg_id,
|
||||
require_match=True):
|
||||
logger.info("[rvs] Sprachnachricht mit ihren Anhaengen gemerged (cmid=%s)",
|
||||
attachment_client_msg_id[:12])
|
||||
return
|
||||
|
||||
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||
@@ -4525,11 +4618,15 @@ class ARIABridge:
|
||||
trigger_name = data.get("trigger_name", "")
|
||||
ttype = data.get("type", "trigger")
|
||||
events = data.get("events") or []
|
||||
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, events=%d",
|
||||
trigger_name, ttype, len(reply), len(events))
|
||||
trigger_pid = str(data.get("projectId") or "")
|
||||
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, "
|
||||
"events=%d, project=%s",
|
||||
trigger_name, ttype, len(reply), len(events),
|
||||
trigger_pid or "(main)")
|
||||
# Async-spawn — HTTP-Antwort nicht durch RVS-Broadcast blockieren
|
||||
asyncio.create_task(
|
||||
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
||||
self._handle_trigger_fired(reply, trigger_name, ttype, events,
|
||||
trigger_pid)
|
||||
)
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/agent-activity":
|
||||
@@ -5140,7 +5237,8 @@ class ARIABridge:
|
||||
return {"ok": True, "role": role, "content_preview": content[:80]}
|
||||
|
||||
async def _handle_trigger_fired(self, reply: str, trigger_name: str,
|
||||
ttype: str, events: list) -> None:
|
||||
ttype: str, events: list,
|
||||
project_id: str = "") -> None:
|
||||
"""Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort.
|
||||
|
||||
Side-Channel-Events zuerst (trigger_created, location_tracking, ...),
|
||||
@@ -5192,7 +5290,12 @@ class ARIABridge:
|
||||
try:
|
||||
await self._process_core_response(
|
||||
reply,
|
||||
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype}},
|
||||
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype},
|
||||
# Ohne projectId landete jede Trigger-Antwort im Hauptchat —
|
||||
# auch wenn der Timer in einem Projekt-Thread gestellt wurde.
|
||||
# Stefan sah dann im Projekt nichts und hielt den Timer fuer
|
||||
# nicht gefeuert.
|
||||
"projectId": project_id or ""},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[trigger-fire] _process_core_response fehlgeschlagen")
|
||||
|
||||
+455
-15
@@ -611,34 +611,74 @@
|
||||
<code>/shared/config/runtime.json</code> als <code>brainModel</code> persistiert.
|
||||
Bei Aenderung: <strong>aria-brain restarten</strong> (Reparatur-Section oben), damit's greift.
|
||||
<br><br>
|
||||
ARIA laeuft ueber dein Claude-Max-Abo (CLI) — waehlbar ist der <strong>Tier</strong>
|
||||
(Opus/Sonnet/Haiku), nicht eine feste Version. Die CLI nimmt automatisch das
|
||||
jeweils aktuelle Modell des Tiers.
|
||||
ARIA laeuft ueber dein Claude-Max-Abo (CLI). Du hast zwei Möglichkeiten:
|
||||
<br>
|
||||
<strong>Automatisch</strong> (Opus/Sonnet/Haiku/Fable) — folgt immer der neuesten
|
||||
Version dieses Tiers, ohne dass du etwas pflegen musst. Für den Alltag die
|
||||
richtige Wahl.
|
||||
<br>
|
||||
<strong>Feste Version</strong> (z.B. „Opus 5.5") — genau dieses Modell, auch wenn
|
||||
später ein neueres kommt.
|
||||
<br><br>
|
||||
Die Auswahlliste kommt aus <code>/shared/config/models.json</code> — dort kannst du
|
||||
Tiers/Beschreibungen anpassen (kein Neustart noetig, danach „↻ Aktualisieren").
|
||||
Die Liste kommt aus Anthropics eigenem <strong>Modell-Katalog</strong>
|
||||
(<code>downloads.claude.ai</code>) — dieselbe Quelle, aus der sich Claude Code
|
||||
selbst bedient. „↻ Aktualisieren" holt sie frisch; sonst gilt ein 6-Stunden-Cache.
|
||||
Die automatischen Tiers kommen weiterhin aus
|
||||
<code>/shared/config/models.json</code>.
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Aktives Model:</span>
|
||||
<select id="setting-model-select" onchange="onModelSelectChange()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
|
||||
<option value="">(lade Liste…)</option>
|
||||
</select>
|
||||
<button class="btn secondary" onclick="loadModelList()" title="Liste vom Proxy neu holen" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
|
||||
<button class="btn secondary" onclick="loadModelList(true)" title="Modell-Liste frisch von Anthropic holen (Modell-Katalog)" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
|
||||
<button class="btn secondary" onclick="testModel()" id="btn-test-model" title="Schickt eine echte Mini-Anfrage und zeigt, welches Modell geantwortet hat" style="padding:4px 8px;font-size:10px;">🔬 Testen</button>
|
||||
<button class="btn" onclick="saveModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
|
||||
</div>
|
||||
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
|
||||
<div id="model-test-result" style="font-size:11px;color:#8888AA;margin-bottom:8px;padding:6px 8px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;display:none;white-space:pre-wrap;"></div>
|
||||
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
|
||||
<details style="font-size:11px;color:#8888AA;">
|
||||
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<input type="text" id="setting-model" placeholder="z.B. claude-opus-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
|
||||
<input type="text" id="setting-model" placeholder="z.B. claude-opus-5-5" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
|
||||
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
|
||||
<button class="btn secondary" onclick="testModel(true)" style="padding:4px 8px;font-size:10px;" title="Diese ID testen, bevor du sie setzt">🔬 Testen</button>
|
||||
<button class="btn" onclick="saveModelFreeText()" style="padding:4px 8px;font-size:10px;">Setzen</button>
|
||||
</div>
|
||||
<div style="color:#555570;font-size:10px;margin-top:6px;">
|
||||
Erst testen, dann setzen — ein Tippfehler im Namen fällt sonst erst
|
||||
auf, wenn ARIA nicht mehr antwortet.
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
|
||||
<div class="card" style="max-width:620px;">
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:12px;line-height:1.55;">
|
||||
Drei Kanäle, über die Claude Code <strong>zusätzlich</strong> zur eigentlichen
|
||||
Anfrage Daten verschickt. Gesetzt wird in <code>~/.claude/settings.json</code> —
|
||||
greift bei jeder neuen Anfrage, <strong>kein Neustart nötig</strong>.
|
||||
<br><br>
|
||||
Davon unabhängig ist, ob Anthropic mit deinen Unterhaltungen Modelle
|
||||
trainiert — das steht in deinem Konto unter
|
||||
<a href="https://claude.ai/settings/data-privacy-controls" target="_blank"
|
||||
style="color:#0096FF;">Datenschutz → „Help improve our AI models"</a>
|
||||
und lässt sich hier nicht umstellen.
|
||||
</div>
|
||||
<div id="privacy-flags-list" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<span style="color:#555570;font-size:11px;">(noch nicht geprüft)</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap;">
|
||||
<button class="btn secondary" onclick="loadPrivacyFlags()" style="padding:5px 12px;font-size:11px;">🔍 Prüfen</button>
|
||||
<button class="btn" onclick="savePrivacyFlags()" style="padding:5px 12px;font-size:11px;">Speichern</button>
|
||||
</div>
|
||||
<div id="privacy-flags-status" style="margin-top:10px;font-size:11px;color:#8888AA;white-space:pre-wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Lokales LLM (schnelle Antworten) <button class="info-btn" onclick="showInfo('local-llm')" title="Wie funktioniert das?">ℹ</button></h2>
|
||||
<div class="card" style="max-width:540px;">
|
||||
@@ -1051,6 +1091,31 @@
|
||||
|
||||
<div class="settings-subtab" data-sub="system">
|
||||
|
||||
<!-- Anhang-Limit -->
|
||||
<div class="settings-section">
|
||||
<h2>Grösse von Anhängen</h2>
|
||||
<div class="card" style="max-width:560px;line-height:1.6;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;">
|
||||
Grösste Datei, die an einen Gedanken angehängt werden darf.
|
||||
Greift <strong>sofort</strong> — kein Neustart nötig.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<input type="number" id="attachment-limit-input" min="1" max="1024" step="1"
|
||||
style="width:110px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;
|
||||
color:#E0E0F0;padding:8px 10px;font-size:13px;">
|
||||
<span style="color:#8888AA;font-size:12px;">MB</span>
|
||||
<button class="btn secondary" onclick="saveAttachmentLimit()">Speichern</button>
|
||||
</div>
|
||||
<p style="color:#8888AA;font-size:11px;margin:10px 0 0;">
|
||||
Über <strong>64 MB</strong> lädt die Handy-App Anhänge nicht mehr herunter —
|
||||
ein Anhang muss als Base64 komplett in den Speicher, und daran ist die App
|
||||
schon einmal gestorben. Er liegt dann sauber auf dem Server, ist mobil aber
|
||||
nur noch über den Dateimanager erreichbar.
|
||||
</p>
|
||||
<div id="attachment-limit-status" style="margin-top:10px;font-size:11px;color:#8888AA;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reparatur & Restart -->
|
||||
<div class="settings-section">
|
||||
<h2>Reparatur & Restart</h2>
|
||||
@@ -1371,9 +1436,42 @@
|
||||
<option value="pinned">📌 Nur Pinned</option>
|
||||
<option value="cold">Nur Cold</option>
|
||||
</select>
|
||||
<select id="brain-sort" onchange="onBrainFiltersChanged()"
|
||||
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;"
|
||||
title="Sortierung der Liste">
|
||||
<option value="type">Nach Typ gruppiert</option>
|
||||
<option value="updated_desc">Neueste Änderung zuerst</option>
|
||||
<option value="updated_asc">Älteste Änderung zuerst</option>
|
||||
<option value="created_desc">Neueste Anlage zuerst</option>
|
||||
<option value="created_asc">Älteste Anlage zuerst</option>
|
||||
</select>
|
||||
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
|
||||
<button class="btn secondary" onclick="toggleDateFilter()" id="btn-date-filter" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Nach Zeitraum filtern">🕒 Zeitraum</button>
|
||||
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
|
||||
</div>
|
||||
<div id="brain-date-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px;color:#8888AA;">
|
||||
<select id="brain-date-field"
|
||||
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
|
||||
<option value="updated">geändert</option>
|
||||
<option value="created">angelegt</option>
|
||||
</select>
|
||||
<span>von</span>
|
||||
<input type="datetime-local" id="brain-date-from"
|
||||
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
|
||||
<span>bis</span>
|
||||
<input type="datetime-local" id="brain-date-to"
|
||||
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
|
||||
<button class="btn" onclick="onBrainFiltersChanged()" style="padding:4px 12px;font-size:11px;">Filtern</button>
|
||||
<button class="btn secondary" onclick="clearDateFilter()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Leeren</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;">
|
||||
<button class="btn secondary" onclick="setDateQuick(1)" style="padding:3px 9px;font-size:10px;">Heute</button>
|
||||
<button class="btn secondary" onclick="setDateQuick(7)" style="padding:3px 9px;font-size:10px;">7 Tage</button>
|
||||
<button class="btn secondary" onclick="setDateQuick(30)" style="padding:3px 9px;font-size:10px;">30 Tage</button>
|
||||
<span style="color:#555570;font-size:10px;margin-left:auto;">Nur ein Feld ausfüllen = offenes Ende</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
|
||||
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
|
||||
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
|
||||
@@ -1732,7 +1830,7 @@
|
||||
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
|
||||
<span>📎 Anhaenge</span>
|
||||
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span>
|
||||
<span class="attachment-limit-label" style="color:#555570;font-size:10px;">Limit: Einstellungen → System & Wartung</span>
|
||||
</label>
|
||||
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
@@ -2650,6 +2748,77 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'privacy_flags') { renderPrivacyFlags(msg); return; }
|
||||
if (msg.type === 'privacy_flags_saved') {
|
||||
const st = document.getElementById('privacy-flags-status');
|
||||
if (st && msg.error) { st.textContent = 'Speichern fehlgeschlagen: ' + msg.error; st.style.color = '#FF6B6B'; }
|
||||
// Erfolg: der direkt folgende privacy_flags-Broadcast malt den Stand neu.
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'model_test_result') {
|
||||
const box = document.getElementById('model-test-result');
|
||||
const btn = document.getElementById('btn-test-model');
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🔬 Testen'; }
|
||||
if (box) {
|
||||
box.style.display = 'block';
|
||||
const secs = msg.tookMs ? ' · ' + (msg.tookMs / 1000).toFixed(1) + 's' : '';
|
||||
if (msg.error) {
|
||||
box.textContent = '❌ "' + msg.requested + '" hat nicht funktioniert'
|
||||
+ secs + '\n' + msg.error;
|
||||
box.style.color = '#FF6B6B';
|
||||
} else {
|
||||
// Zeigt ausdruecklich BEIDES: was angefragt wurde und was
|
||||
// tatsaechlich lief.
|
||||
const TIERS = ['opus', 'sonnet', 'haiku', 'fable'];
|
||||
const differs = msg.actual !== msg.requested;
|
||||
const isTier = TIERS.includes(msg.requested);
|
||||
const win = msg.contextWindow
|
||||
? '\nKontext: ' + Math.round(msg.contextWindow / 1000) + 'k Token'
|
||||
+ (msg.maxOutput ? ', max. Ausgabe ' + Math.round(msg.maxOutput / 1000) + 'k' : '')
|
||||
: '';
|
||||
const tail = win + (msg.reply ? '\nAntwort: ' + msg.reply : '');
|
||||
if (differs && !isTier) {
|
||||
// Ein nicht erkannter Name wird vom Proxy durch "opus" ersetzt.
|
||||
// Das darf NICHT wie ein Erfolg aussehen — sonst setzt man eine
|
||||
// Modell-ID, die nie benutzt wird, und merkt es nie.
|
||||
box.textContent = '⚠ "' + msg.requested + '" wurde nicht erkannt' + secs
|
||||
+ '\nStattdessen geantwortet hat: ' + msg.actual
|
||||
+ '\nDiese ID so NICHT setzen — tippe den vollen Namen (z.B. '
|
||||
+ 'claude-opus-5-5) oder nimm einen Eintrag aus der Liste.' + tail;
|
||||
box.style.color = '#FFD60A';
|
||||
} else {
|
||||
box.textContent = '✅ Geantwortet hat: ' + msg.actual + secs
|
||||
+ (differs ? '\n(angefragt war der Tier "' + msg.requested
|
||||
+ '" — aufgelöst auf diese Version)' : '')
|
||||
+ tail;
|
||||
box.style.color = '#3FFF9F';
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'attachment_limit_info') {
|
||||
const inp = document.getElementById('attachment-limit-input');
|
||||
const st = document.getElementById('attachment-limit-status');
|
||||
if (inp && msg.maxMb != null) inp.value = msg.maxMb;
|
||||
// Das Label an der Upload-Stelle im Gehirn-Tab mitziehen, damit dort
|
||||
// nicht weiter ein festes "max 20 MB" steht das nicht mehr stimmt.
|
||||
if (msg.maxMb != null) {
|
||||
attachmentLimitMb = msg.maxMb;
|
||||
document.querySelectorAll('.attachment-limit-label').forEach(el => {
|
||||
el.textContent = 'max ' + msg.maxMb + ' MB pro Datei';
|
||||
});
|
||||
}
|
||||
if (st) {
|
||||
st.textContent = msg.error ? ('Fehler: ' + msg.error) : (msg.info || '');
|
||||
st.style.color = msg.error ? '#FF6B6B'
|
||||
: (msg.maxMb > (msg.softMb || 64) ? '#FFD60A' : '#3FFF9F');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
|
||||
if (msg.type === 'model_info') {
|
||||
const el = document.getElementById('setting-model');
|
||||
@@ -4543,6 +4712,22 @@
|
||||
|
||||
// ── Haupt-Tab Navigation ──────────────────────────────────
|
||||
|
||||
// Zuletzt bekanntes Anhang-Limit (MB) — nur fuer Anzeige/Vorab-Pruefung.
|
||||
// Die verbindliche Pruefung macht das Brain.
|
||||
let attachmentLimitMb = null;
|
||||
|
||||
function saveAttachmentLimit() {
|
||||
const inp = document.getElementById('attachment-limit-input');
|
||||
const st = document.getElementById('attachment-limit-status');
|
||||
const v = Number(inp && inp.value);
|
||||
if (!isFinite(v) || v <= 0) {
|
||||
if (st) { st.textContent = 'Bitte eine Zahl grösser 0 angeben.'; st.style.color = '#FF6B6B'; }
|
||||
return;
|
||||
}
|
||||
if (st) { st.textContent = 'Speichere …'; st.style.color = '#8888AA'; }
|
||||
send({ action: 'set_attachment_limit', maxMb: v });
|
||||
}
|
||||
|
||||
function switchSettingsTab(sub) {
|
||||
sub = sub || 'models';
|
||||
document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub));
|
||||
@@ -4567,9 +4752,13 @@
|
||||
loadOAuthServices();
|
||||
loadJellyfinConfig();
|
||||
refreshVoiceIdStatus();
|
||||
send({ action: 'get_attachment_limit' });
|
||||
send({ action: 'get_privacy_flags' });
|
||||
} else if (tab === 'brain') {
|
||||
loadBrainStatus();
|
||||
loadBrainMemoryList();
|
||||
// Damit das Anhang-Label die echte Zahl zeigt, nicht eine fest eingetippte.
|
||||
send({ action: 'get_attachment_limit' });
|
||||
refreshImportFiles();
|
||||
loadMetrics();
|
||||
loadProjects();
|
||||
@@ -6493,6 +6682,14 @@
|
||||
const t = document.getElementById('brain-filter-type'); if (t) t.value = '';
|
||||
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
|
||||
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
|
||||
const so = document.getElementById('brain-sort'); if (so) so.value = 'type';
|
||||
// Zeitraum-Panel zu + leeren, sonst filtert es nach dem ✕ unsichtbar weiter
|
||||
const dp = document.getElementById('brain-date-panel');
|
||||
if (dp) dp.style.display = 'none';
|
||||
const db = document.getElementById('btn-date-filter');
|
||||
if (db) db.style.color = '#8888AA';
|
||||
const df = document.getElementById('brain-date-from'); if (df) df.value = '';
|
||||
const dt = document.getElementById('brain-date-to'); if (dt) dt.value = '';
|
||||
brainSearchIds = null;
|
||||
clearAdvancedSearch();
|
||||
}
|
||||
@@ -6745,7 +6942,10 @@
|
||||
if (!el) return;
|
||||
// Wenn aktive Search-Treffer da sind: die anzeigen, nicht neu laden
|
||||
if (brainSearchIds && brainSearchIds.length) {
|
||||
const items = brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean);
|
||||
// Zeitraum gilt auch fuer Such-Treffer — sonst waere "suche X,
|
||||
// aber nur von letzter Woche" nicht moeglich.
|
||||
const items = applyDateFilter(
|
||||
brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean));
|
||||
renderBrainList(items, true);
|
||||
return;
|
||||
}
|
||||
@@ -6761,8 +6961,12 @@
|
||||
items.forEach(m => { brainMemoryCache[m.id] = m; });
|
||||
if (pinnedFilter === 'pinned') items = items.filter(m => m.pinned);
|
||||
else if (pinnedFilter === 'cold') items = items.filter(m => !m.pinned);
|
||||
const before = items.length;
|
||||
items = applyDateFilter(items);
|
||||
if (!items.length) {
|
||||
el.innerHTML = '(Keine Memories — leere DB oder Filter zu eng)';
|
||||
el.innerHTML = before > 0
|
||||
? `(Kein Gedanke im gewählten Zeitraum — ${before} wurden durch den Zeitfilter ausgeblendet)`
|
||||
: '(Keine Memories — leere DB oder Filter zu eng)';
|
||||
return;
|
||||
}
|
||||
renderBrainList(items, false);
|
||||
@@ -6831,6 +7035,31 @@
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Datum/Uhrzeit eines Gedankens ────────────────────────────────
|
||||
// created_at / updated_at liegen schon immer als ISO-String in der DB —
|
||||
// sie wurden nur nie angezeigt. Darum sah man nicht, was der letzte
|
||||
// Eintrag war.
|
||||
function memTs(m, which) {
|
||||
const raw = which === 'created' ? m.created_at : (m.updated_at || m.created_at);
|
||||
if (!raw) return 0;
|
||||
const t = Date.parse(raw);
|
||||
return isFinite(t) ? t : 0;
|
||||
}
|
||||
function fmtMemTs(ms) {
|
||||
if (!ms) return '';
|
||||
return new Date(ms).toLocaleString('de-DE',
|
||||
{ day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
/** "vor 3 Min" / "vor 2 Std" / "vor 5 Tagen" — fuer "was war der letzte Eintrag". */
|
||||
function fmtMemAgo(ms) {
|
||||
if (!ms) return '';
|
||||
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
||||
if (s < 60) return 'gerade';
|
||||
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`;
|
||||
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`;
|
||||
return `vor ${Math.floor(s / 86400)} Tg`;
|
||||
}
|
||||
|
||||
function renderMemoryRow(m, withScore) {
|
||||
const pin = m.pinned ? '📌 ' : '';
|
||||
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
|
||||
@@ -6844,12 +7073,25 @@
|
||||
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
|
||||
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
|
||||
: '';
|
||||
// Zeitstempel-Zeile: angelegt immer, geaendert nur wenn es sich
|
||||
// unterscheidet (sonst stehen zwei identische Zeiten nebeneinander).
|
||||
const cMs = memTs(m, 'created');
|
||||
const uMs = memTs(m, 'updated');
|
||||
const tsLine = (cMs || uMs)
|
||||
? `<div style="color:#555570;font-size:10px;margin-top:2px;">`
|
||||
+ (cMs ? `angelegt ${fmtMemTs(cMs)}` : '')
|
||||
+ (uMs && Math.abs(uMs - cMs) > 60000
|
||||
? ` · geändert ${fmtMemTs(uMs)} <span style="color:#0096FF;">(${fmtMemAgo(uMs)})</span>`
|
||||
: (cMs ? ` <span style="color:#0096FF;">(${fmtMemAgo(cMs)})</span>` : ''))
|
||||
+ `</div>`
|
||||
: '';
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
|
||||
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||
</div>
|
||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||
${tsLine}
|
||||
</div>
|
||||
<button class="btn secondary" onclick="event.stopPropagation();openMemoryModal('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;" title="Bearbeiten">✎</button>
|
||||
<button class="btn secondary" onclick="event.stopPropagation();deleteMemory('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
|
||||
@@ -6867,6 +7109,71 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Sortiert nach der Auswahl im brain-sort-Dropdown. 'type' = unveraendert. */
|
||||
function sortMemories(items) {
|
||||
const mode = document.getElementById('brain-sort')?.value || 'type';
|
||||
if (mode === 'type') return items;
|
||||
const field = mode.startsWith('created') ? 'created' : 'updated';
|
||||
const desc = mode.endsWith('_desc');
|
||||
// Kopie sortieren — die Aufrufer halten teils denselben Array weiter.
|
||||
return items.slice().sort((a, b) => {
|
||||
const d = memTs(a, field) - memTs(b, field);
|
||||
return desc ? -d : d;
|
||||
});
|
||||
}
|
||||
|
||||
/** Zeitraum-Filter aus dem 🕒-Panel. Leere Felder = offenes Ende. */
|
||||
function applyDateFilter(items) {
|
||||
const panel = document.getElementById('brain-date-panel');
|
||||
if (!panel || panel.style.display === 'none') return items;
|
||||
const field = document.getElementById('brain-date-field')?.value || 'updated';
|
||||
const fromRaw = document.getElementById('brain-date-from')?.value || '';
|
||||
const toRaw = document.getElementById('brain-date-to')?.value || '';
|
||||
const from = fromRaw ? Date.parse(fromRaw) : null;
|
||||
const to = toRaw ? Date.parse(toRaw) : null;
|
||||
if (from == null && to == null) return items;
|
||||
return items.filter(m => {
|
||||
const t = memTs(m, field);
|
||||
if (!t) return false; // ohne Zeitstempel nicht in einen Zeitraum einsortierbar
|
||||
if (from != null && t < from) return false;
|
||||
if (to != null && t > to) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDateFilter() {
|
||||
const panel = document.getElementById('brain-date-panel');
|
||||
const btn = document.getElementById('btn-date-filter');
|
||||
if (!panel) return;
|
||||
const open = panel.style.display === 'none';
|
||||
panel.style.display = open ? 'block' : 'none';
|
||||
if (btn) btn.style.color = open ? '#FFD60A' : '#8888AA';
|
||||
if (!open) loadBrainMemoryList(); // zugeklappt = Filter aus
|
||||
}
|
||||
|
||||
function clearDateFilter() {
|
||||
const f = document.getElementById('brain-date-from');
|
||||
const t = document.getElementById('brain-date-to');
|
||||
if (f) f.value = '';
|
||||
if (t) t.value = '';
|
||||
loadBrainMemoryList();
|
||||
}
|
||||
|
||||
/** Schnellwahl: letzte N Tage (0 Uhr bis jetzt). */
|
||||
function setDateQuick(days) {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - (days - 1) * 86400000);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const ef = document.getElementById('brain-date-from');
|
||||
const et = document.getElementById('brain-date-to');
|
||||
if (ef) ef.value = fmt(from);
|
||||
if (et) et.value = fmt(now);
|
||||
loadBrainMemoryList();
|
||||
}
|
||||
|
||||
function renderBrainList(items, isSearchResult) {
|
||||
const el = document.getElementById('brain-memory-list');
|
||||
if (!el) return;
|
||||
@@ -6875,11 +7182,22 @@
|
||||
_updateCategoryDatalist(items);
|
||||
|
||||
if (isSearchResult) {
|
||||
// Such-Treffer: in Aehnlichkeits-Reihenfolge, kein Type-Gruppieren
|
||||
const html = items.map(m => renderMemoryRow(m, true)).join('');
|
||||
// Such-Treffer: Aehnlichkeits-Reihenfolge, ausser es ist bewusst eine
|
||||
// Zeit-Sortierung gewaehlt — dann gilt die auch fuer Treffer.
|
||||
const sorted = sortMemories(items);
|
||||
const html = sorted.map(m => renderMemoryRow(m, true)).join('');
|
||||
el.innerHTML = html || '(Keine Treffer)';
|
||||
return;
|
||||
}
|
||||
// Zeit-Sortierung gewaehlt → flache Liste, keine Typ-Gruppen. Das ist der
|
||||
// Sinn der Sache: man will sehen, was zuletzt passiert ist, nicht in
|
||||
// welche Schublade es gehoert.
|
||||
const sortMode = document.getElementById('brain-sort')?.value || 'type';
|
||||
if (sortMode !== 'type') {
|
||||
const html = sortMemories(items).map(m => renderMemoryRow(m, false)).join('');
|
||||
el.innerHTML = html || '(Keine Memories)';
|
||||
return;
|
||||
}
|
||||
// Normale Liste: nach Type gruppieren, Header klappbar
|
||||
const byType = {};
|
||||
items.forEach(m => { (byType[m.type] = byType[m.type] || []).push(m); });
|
||||
@@ -7673,17 +7991,46 @@
|
||||
|
||||
// Kuratierte Tier-Liste vom Proxy holen (via Diagnostic-Server) und ins
|
||||
// Dropdown fuellen. Danach get_model, damit das aktive Model markiert wird.
|
||||
async function loadModelList() {
|
||||
// force=true (Knopf „↻ Aktualisieren") holt die Liste frisch von Anthropic
|
||||
// statt aus dem Proxy-Cache.
|
||||
async function loadModelList(force) {
|
||||
const sel = document.getElementById('setting-model-select');
|
||||
const st = document.getElementById('model-status');
|
||||
if (!sel) return;
|
||||
if (force && st) { st.textContent = 'Hole Modell-Liste von Anthropic …'; st.style.color = '#8888AA'; }
|
||||
try {
|
||||
const r = await fetch('/api/models-list');
|
||||
const r = await fetch('/api/models-list' + (force ? '?refresh=1' : ''));
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.error || 'Fehler');
|
||||
_modelListCache = d.models || [];
|
||||
sel.innerHTML = '';
|
||||
for (const m of _modelListCache) {
|
||||
// Nach Herkunft gruppieren, damit die Wahl klar ist:
|
||||
// Tier — folgt automatisch der neuesten Version (Empfehlung)
|
||||
// main — die aktuell von Anthropic vorgeschlagenen Versionen
|
||||
// overflow— aeltere Versionen, nur wenn man sie bewusst will
|
||||
const GROUPS = [
|
||||
['tier', 'Automatisch (folgt der neuesten Version)'],
|
||||
['main', 'Aktuelle Versionen'],
|
||||
['overflow', 'Ältere Versionen'],
|
||||
];
|
||||
for (const [section, label] of GROUPS) {
|
||||
const inGroup = _modelListCache.filter(m => (m.section || 'tier') === section);
|
||||
if (!inGroup.length) continue;
|
||||
const g = document.createElement('optgroup');
|
||||
g.label = label;
|
||||
for (const m of inGroup) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.displayName || m.id;
|
||||
g.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(g);
|
||||
}
|
||||
// Unbekannte Sektionen nicht verschlucken (aeltere Proxy-Version
|
||||
// liefert gar kein section-Feld -> die landen oben unter 'tier').
|
||||
const known = new Set(GROUPS.map(g => g[0]));
|
||||
const rest = _modelListCache.filter(m => !known.has(m.section || 'tier'));
|
||||
for (const m of rest) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.displayName || m.id;
|
||||
@@ -7694,6 +8041,10 @@
|
||||
opt.value = ''; opt.textContent = '(keine Modelle vom Proxy)';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (st && d.catalogOk === false) {
|
||||
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
|
||||
st.style.color = '#FFD60A';
|
||||
}
|
||||
onModelSelectChange();
|
||||
// Aktuelles Model vom Brain holen → markiert die richtige Option
|
||||
loadModel();
|
||||
@@ -7702,6 +8053,95 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Datenschutz-Schalter ──────────────────────────────────────────
|
||||
function loadPrivacyFlags() {
|
||||
const st = document.getElementById('privacy-flags-status');
|
||||
if (st) { st.textContent = 'Lese den Proxy-Container …'; st.style.color = '#8888AA'; }
|
||||
send({ action: 'get_privacy_flags' });
|
||||
}
|
||||
|
||||
function savePrivacyFlags() {
|
||||
const flags = {};
|
||||
document.querySelectorAll('#privacy-flags-list input[type=checkbox][data-key]')
|
||||
.forEach(cb => { flags[cb.dataset.key] = cb.checked; });
|
||||
if (!Object.keys(flags).length) {
|
||||
const st = document.getElementById('privacy-flags-status');
|
||||
if (st) { st.textContent = 'Erst „Prüfen" drücken.'; st.style.color = '#FFD60A'; }
|
||||
return;
|
||||
}
|
||||
send({ action: 'set_privacy_flags', flags });
|
||||
}
|
||||
|
||||
function renderPrivacyFlags(msg) {
|
||||
const box = document.getElementById('privacy-flags-list');
|
||||
const st = document.getElementById('privacy-flags-status');
|
||||
if (!box) return;
|
||||
if (msg.error) {
|
||||
box.innerHTML = '';
|
||||
if (st) { st.textContent = 'Fehler: ' + msg.error; st.style.color = '#FF6B6B'; }
|
||||
return;
|
||||
}
|
||||
box.innerHTML = (msg.flags || []).map(f => {
|
||||
// "container" = per docker-compose gesetzt. Das gewinnt gegen
|
||||
// settings.json, also waere ein Haken hier wirkungslos — darum sperren
|
||||
// und ehrlich sagen warum.
|
||||
const locked = f.source === 'container';
|
||||
return '<label style="display:flex;gap:10px;align-items:flex-start;cursor:' + (locked ? 'default' : 'pointer') + ';">'
|
||||
+ '<input type="checkbox" data-key="' + f.key + '"' + (f.disabled ? ' checked' : '')
|
||||
+ (locked ? ' disabled' : '') + ' style="margin-top:3px;">'
|
||||
+ '<span style="flex:1;">'
|
||||
+ '<span style="color:' + (f.disabled ? '#3FFF9F' : '#FFD60A') + ';font-size:12px;">'
|
||||
+ (f.disabled ? '✓ ' : '⚠ ') + escapeHtml(f.label) + '</span>'
|
||||
+ '<span style="color:#555570;font-size:10px;"> — ' + escapeHtml(f.key) + '</span>'
|
||||
+ '<br><span style="color:#8888AA;font-size:10px;">' + escapeHtml(f.info) + '</span>'
|
||||
+ (locked ? '<br><span style="color:#FF9F0A;font-size:10px;">Per docker-compose gesetzt — '
|
||||
+ 'das gewinnt gegen diese Einstellung und lässt sich hier nicht ändern.</span>' : '')
|
||||
+ '</span></label>';
|
||||
}).join('') || '<span style="color:#555570;font-size:11px;">(keine Angaben)</span>';
|
||||
|
||||
const offen = (msg.flags || []).filter(f => !f.disabled).length;
|
||||
if (st) {
|
||||
if (msg.bulkDisabled) {
|
||||
st.textContent = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC ist gesetzt — das schaltet ohnehin alles ab.';
|
||||
st.style.color = '#3FFF9F';
|
||||
} else if (offen === 0) {
|
||||
st.textContent = '✅ Alle drei Kanäle sind zu.';
|
||||
st.style.color = '#3FFF9F';
|
||||
} else {
|
||||
st.textContent = '⚠ ' + offen + ' von ' + (msg.flags || []).length + ' Kanälen senden noch. '
|
||||
+ 'Haken setzen und speichern.';
|
||||
st.style.color = '#FFD60A';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Testet ein Modell mit einer echten Mini-Anfrage.
|
||||
* fromFreeText=true nimmt die handgetippte ID, sonst das Dropdown.
|
||||
*
|
||||
* Was hier NICHT passiert: den Dropdown-Wert zurueckgeben. Angezeigt wird,
|
||||
* was die CLI als tatsaechlich benutztes Modell meldet — bei einem Alias
|
||||
* also die aufgeloeste Version, bei einem Tippfehler der Fehler.
|
||||
*/
|
||||
function testModel(fromFreeText) {
|
||||
const model = fromFreeText
|
||||
? (document.getElementById('setting-model')?.value || '').trim()
|
||||
: (document.getElementById('setting-model-select')?.value || '');
|
||||
const box = document.getElementById('model-test-result');
|
||||
if (!box) return;
|
||||
box.style.display = 'block';
|
||||
if (!model) {
|
||||
box.textContent = 'Kein Modell ausgewählt.';
|
||||
box.style.color = '#FF6B6B';
|
||||
return;
|
||||
}
|
||||
box.textContent = `Teste "${model}" — schicke eine echte Anfrage …`;
|
||||
box.style.color = '#8888AA';
|
||||
const btn = document.getElementById('btn-test-model');
|
||||
if (btn && !fromFreeText) { btn.disabled = true; btn.textContent = '🔬 läuft …'; }
|
||||
send({ action: 'test_model', model });
|
||||
}
|
||||
|
||||
function onModelSelectChange() {
|
||||
const sel = document.getElementById('setting-model-select');
|
||||
const desc = document.getElementById('model-desc');
|
||||
|
||||
+255
-3
@@ -268,6 +268,9 @@ const RUNTIME_CONFIG_FIELDS = [
|
||||
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
||||
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
||||
"brainModel",
|
||||
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
|
||||
// aus derselben Datei — Aenderung greift ohne Neustart.
|
||||
"memoryAttachmentMaxMb",
|
||||
];
|
||||
function readRuntimeConfig() {
|
||||
const envDefaults = {
|
||||
@@ -2201,22 +2204,27 @@ const server = http.createServer((req, res) => {
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
return;
|
||||
} else if (req.url === "/api/models-list" && req.method === "GET") {
|
||||
} else if (req.url.startsWith("/api/models-list") && req.method === "GET") {
|
||||
// Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
|
||||
// Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
|
||||
// der Tier (opus/sonnet/haiku), keine feste Version.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`${PROXY_URL}/v1/models`);
|
||||
// ?refresh=1 weiterreichen: der Proxy umgeht damit seinen Katalog-Cache
|
||||
// und holt die Liste frisch von Anthropic („↻ Aktualisieren").
|
||||
const refresh = /[?&]refresh=1\b/.test(req.url) ? "?refresh=1" : "";
|
||||
const r = await fetch(`${PROXY_URL}/v1/models${refresh}`);
|
||||
const d = await r.json();
|
||||
const models = (d.data || []).map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier || m.id,
|
||||
displayName: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
// section: tier | main | overflow — die UI gruppiert danach.
|
||||
section: m.section || "tier",
|
||||
}));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, models }));
|
||||
res.end(JSON.stringify({ ok: true, models, catalogOk: d.catalog_ok !== false }));
|
||||
} catch (err) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
|
||||
@@ -3207,6 +3215,16 @@ wss.on("connection", (ws) => {
|
||||
handleGetModel(ws);
|
||||
} else if (msg.action === "set_model") {
|
||||
handleSetModel(ws, msg.model);
|
||||
} else if (msg.action === "test_model") {
|
||||
handleTestModel(ws, msg.model);
|
||||
} else if (msg.action === "get_privacy_flags") {
|
||||
handleGetPrivacyFlags(ws);
|
||||
} else if (msg.action === "set_privacy_flags") {
|
||||
handleSetPrivacyFlags(ws, msg.flags);
|
||||
} else if (msg.action === "get_attachment_limit") {
|
||||
handleGetAttachmentLimit(ws);
|
||||
} else if (msg.action === "set_attachment_limit") {
|
||||
handleSetAttachmentLimit(ws, msg.maxMb);
|
||||
} else if (msg.action === "voice_id_status") {
|
||||
// An whisper-bridge weiterleiten + Antwort an Browser zurueck
|
||||
const reqId = `vid_${Date.now().toString(36)}`;
|
||||
@@ -3629,6 +3647,240 @@ function handleSetModel(clientWs, model) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Einstellungen: Datenschutz-Schalter von Claude Code ─────────────
|
||||
//
|
||||
// Drei Kanaele, ueber die Claude Code von sich aus Daten an Anthropic bzw. an
|
||||
// einen Fehler-Dienst schickt. Sie liegen als Umgebungsvariablen vor und lassen
|
||||
// sich in ~/.claude/settings.json unter "env" setzen — die Datei ist in den
|
||||
// Proxy-Container gemountet, also koennen wir sie von hier aus pflegen, ohne
|
||||
// die docker-compose.yml anzufassen.
|
||||
//
|
||||
// WICHTIG: gesetzt wird in settings.json. Steht dieselbe Variable ZUSAETZLICH
|
||||
// als echte Container-Umgebungsvariable (docker-compose), gewinnt die — darum
|
||||
// prueft "Pruefen" beides und sagt, was tatsaechlich im Prozess ankommt.
|
||||
const PRIVACY_FLAGS = [
|
||||
{ key: "DISABLE_ERROR_REPORTING",
|
||||
label: "Fehlerberichte",
|
||||
info: "Stacktraces an einen Fehler-Dienst. Bei Pro/Max ab CLI 2.1.198 standardmäßig AN." },
|
||||
{ key: "DISABLE_TELEMETRY",
|
||||
label: "Nutzungs-Metriken",
|
||||
info: "Latenz/Zuverlässigkeit. Enthält laut Anthropic nie Code, Prompts oder Pfade." },
|
||||
{ key: "DISABLE_FEEDBACK_COMMAND",
|
||||
label: "/feedback-Versand",
|
||||
info: "Sendet sonst auf Zuruf die ganze Unterhaltung inkl. Code — 5 Jahre Aufbewahrung." },
|
||||
];
|
||||
const CLAUDE_SETTINGS_PATH = "/root/.claude/settings.json";
|
||||
|
||||
function _shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
||||
|
||||
async function handleGetPrivacyFlags(clientWs) {
|
||||
try {
|
||||
// 1) Was kommt im PROZESS wirklich an? Das ist die Wahrheit.
|
||||
const envOut = await dockerExec("aria-proxy",
|
||||
"env | grep -E '^(DISABLE_ERROR_REPORTING|DISABLE_TELEMETRY|DISABLE_FEEDBACK_COMMAND|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)=' || true");
|
||||
const liveEnv = {};
|
||||
for (const line of String(envOut || "").split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) liveEnv[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
// 2) Was steht in settings.json?
|
||||
const settingsRaw = await dockerExec("aria-proxy",
|
||||
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let settingsEnv = {};
|
||||
try { settingsEnv = (JSON.parse(settingsRaw || "{}").env) || {}; } catch (_) {}
|
||||
|
||||
const isOn = (v) => v === "1" || String(v).toLowerCase() === "true";
|
||||
const flags = PRIVACY_FLAGS.map(f => {
|
||||
const fromEnv = liveEnv[f.key];
|
||||
const fromSettings = settingsEnv[f.key];
|
||||
// Container-Env gewinnt gegen settings.json.
|
||||
const effective = fromEnv !== undefined ? isOn(fromEnv) : isOn(fromSettings);
|
||||
return {
|
||||
key: f.key, label: f.label, info: f.info,
|
||||
disabled: effective,
|
||||
source: fromEnv !== undefined ? "container" : (fromSettings !== undefined ? "settings" : "none"),
|
||||
};
|
||||
});
|
||||
const bulk = isOn(liveEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC);
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags", flags, bulkDisabled: bulk }));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags", error: String((err && err.message) || err) }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetPrivacyFlags(clientWs, wanted) {
|
||||
try {
|
||||
const keys = PRIVACY_FLAGS.map(f => f.key);
|
||||
const raw = await dockerExec("aria-proxy",
|
||||
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(raw || "{}"); } catch (_) { cfg = {}; }
|
||||
if (!cfg.env || typeof cfg.env !== "object") cfg.env = {};
|
||||
for (const k of keys) {
|
||||
if (wanted && wanted[k]) cfg.env[k] = "1";
|
||||
else delete cfg.env[k];
|
||||
}
|
||||
if (Object.keys(cfg.env).length === 0) delete cfg.env;
|
||||
|
||||
// Atomar schreiben (temp + mv), damit eine laufende CLI nie eine halbe
|
||||
// Datei liest.
|
||||
const json = JSON.stringify(cfg, null, 2);
|
||||
await dockerExec("aria-proxy",
|
||||
`printf '%s' ${_shq(json)} > ${CLAUDE_SETTINGS_PATH}.tmp && mv ${CLAUDE_SETTINGS_PATH}.tmp ${CLAUDE_SETTINGS_PATH}`);
|
||||
log("info", "server", `Datenschutz-Schalter gesetzt: ${JSON.stringify(wanted || {})}`);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "privacy_flags_saved",
|
||||
info: "Gespeichert. Gilt für JEDE neue Anfrage — ein laufender Turn nutzt noch die alte Einstellung.",
|
||||
}));
|
||||
await handleGetPrivacyFlags(clientWs);
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags_saved", error: String((err && err.message) || err) }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Einstellungen: Model-Test ───────────────────────────────────────
|
||||
//
|
||||
// Schickt eine winzige ECHTE Anfrage durch den kompletten Weg
|
||||
// (Diagnostic -> Proxy -> extractModel -> claude CLI -> Anthropic) und meldet,
|
||||
// welches Modell tatsaechlich geantwortet hat.
|
||||
//
|
||||
// Wichtig: wir fragen NICHT das Modell selbst ("welche Version bist du?") —
|
||||
// darauf kann man sich nicht verlassen, Modelle kennen ihren eigenen
|
||||
// Versionsstring oft nicht und raten. Die Wahrheit kommt aus modelUsage der
|
||||
// CLI (Feld canonicalModel): das ist der Name, unter dem der Call wirklich
|
||||
// lief. Bei einem Alias sieht man damit die aufgeloeste Version
|
||||
// ("opus" -> "claude-opus-5-5"), bei einem ungueltigen Namen den Fehler.
|
||||
function handleTestModel(clientWs, model) {
|
||||
const m = String(model || "").trim();
|
||||
if (!m) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result", error: "Kein Modell angegeben" }));
|
||||
return;
|
||||
}
|
||||
const started = Date.now();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`${PROXY_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: m,
|
||||
// Kurz halten: der Test soll Sekunden kosten, nicht Minuten.
|
||||
messages: [{ role: "user", content: "Antworte nur mit: OK" }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
});
|
||||
const d = await r.json().catch(() => null);
|
||||
if (!r.ok || !d) {
|
||||
throw new Error(`Proxy antwortete HTTP ${r.status}`);
|
||||
}
|
||||
const tookMs = Date.now() - started;
|
||||
const reply = (d.choices && d.choices[0] && d.choices[0].message
|
||||
&& d.choices[0].message.content) || "";
|
||||
// Kein Modell in modelUsage ODER is_error => der Call kam nie
|
||||
// zustande. Die CLI legt ihre Begruendung dann in den Text.
|
||||
if (d.aria_error || !d.aria_model) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result",
|
||||
requested: m,
|
||||
error: (reply || "Modell hat nicht geantwortet").slice(0, 400),
|
||||
tookMs,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result",
|
||||
requested: m,
|
||||
actual: d.aria_model,
|
||||
contextWindow: d.aria_context_window || 0,
|
||||
maxOutput: d.aria_max_output || 0,
|
||||
reply: reply.slice(0, 120),
|
||||
tookMs,
|
||||
}));
|
||||
log("info", "server", `Model-Test: ${m} -> ${d.aria_model} (${tookMs}ms)`);
|
||||
} catch (err) {
|
||||
const msg = err && err.name === "TimeoutError"
|
||||
? "Zeitüberschreitung (3 min) — Proxy oder CLI antwortet nicht"
|
||||
: String((err && err.message) || err);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result", requested: m, error: msg,
|
||||
tookMs: Date.now() - started,
|
||||
}));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Einstellungen: Groessen-Limit fuer Memory-Anhaenge ──────────────
|
||||
// Steht in runtime.json als `memoryAttachmentMaxMb`. Das Brain liest es bei
|
||||
// JEDEM Upload neu (memory_attachments.max_bytes()) — eine Aenderung greift
|
||||
// also sofort, ohne aria-brain neu zu starten.
|
||||
//
|
||||
// Obergrenze mit Bedacht: der Anhang geht als base64-in-JSON zur App, die muss
|
||||
// ihn komplett in den Speicher nehmen. Ueber ~64 MB laedt das Handy ihn nicht
|
||||
// mehr (TAP_DL_MAX_BYTES in ChatScreen.tsx) — dann liegt er zwar sauber auf dem
|
||||
// Server, ist mobil aber nur noch per Dateimanager erreichbar. Darum warnen wir
|
||||
// darueber, verbieten es aber nicht.
|
||||
const ATTACHMENT_LIMIT_SOFT_MB = 64;
|
||||
const ATTACHMENT_LIMIT_MAX_MB = 1024;
|
||||
|
||||
function handleGetAttachmentLimit(clientWs) {
|
||||
// Das Brain ist die Wahrheit — es kennt auch den ENV-Fallback, den der
|
||||
// Diagnostic-Container nicht sieht.
|
||||
const brain = process.env.BRAIN_URL || "http://aria-brain:8080";
|
||||
const req = http.get(`${brain}/attachments/limit`, { timeout: 5000 }, (r) => {
|
||||
let body = "";
|
||||
r.on("data", (c) => { body += c; });
|
||||
r.on("end", () => {
|
||||
let maxMb = null;
|
||||
try { maxMb = JSON.parse(body).maxMb; } catch (_) {}
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
maxMb,
|
||||
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
||||
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
||||
error: maxMb == null ? "Brain antwortet nicht verwertbar" : undefined,
|
||||
}));
|
||||
});
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(new Error("Brain antwortet nicht")); });
|
||||
}
|
||||
|
||||
function handleSetAttachmentLimit(clientWs, maxMb) {
|
||||
const n = Number(maxMb);
|
||||
if (!isFinite(n) || n <= 0) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info", error: "Bitte eine Zahl groesser 0 angeben (MB)" }));
|
||||
return;
|
||||
}
|
||||
if (n > ATTACHMENT_LIMIT_MAX_MB) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
error: `Maximal ${ATTACHMENT_LIMIT_MAX_MB} MB — darueber reisst der WebSocket-Transport` }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeRuntimeConfig({ memoryAttachmentMaxMb: n });
|
||||
log("info", "server", `Anhang-Limit gesetzt: ${n} MB`);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
maxMb: n,
|
||||
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
||||
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
||||
info: n > ATTACHMENT_LIMIT_SOFT_MB
|
||||
? `${n} MB gesetzt (greift sofort). Hinweis: ueber ${ATTACHMENT_LIMIT_SOFT_MB} MB `
|
||||
+ `laedt die Handy-App Anhaenge nicht mehr herunter — sie liegen dann nur `
|
||||
+ `noch auf dem Server.`
|
||||
: `${n} MB gesetzt — greift sofort, kein Neustart noetig.`,
|
||||
}));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw-Config-Handler entfernt — aria-core ist raus.
|
||||
|
||||
// ── Start ───────────────────────────────────────────────
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 102
|
||||
versionName '0.0.1.2'
|
||||
versionCode 104
|
||||
versionName '0.0.1.4'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
|
||||
@@ -36,7 +36,7 @@ class RvsClient(
|
||||
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
|
||||
val voice: VoiceSession by lazy {
|
||||
VoiceSession(appCtx, config,
|
||||
send = { t, p -> ws?.let { send(it, t, p) } },
|
||||
send = { t, p -> ws?.let { send(it, t, p) } ?: false },
|
||||
onEvent = { msg -> onStatus(true, msg) })
|
||||
}
|
||||
|
||||
@@ -112,12 +112,21 @@ class RvsClient(
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
|
||||
/**
|
||||
* Verschickt eine Nachricht. Gibt zurueck, ob der Socket sie ANGENOMMEN hat.
|
||||
*
|
||||
* Wichtig: OkHttps `send()` WIRFT NICHT, sondern gibt `false` zurueck, wenn
|
||||
* die Verbindung zu ist oder der Ausgangspuffer voll ist. Dieser Rueckgabe-
|
||||
* wert wurde vorher verworfen — ein Senden ins Leere sah damit aus wie ein
|
||||
* Erfolg. Genau daran ist die Fire-TV-Sprachausgabe lautlos gescheitert und
|
||||
* hat trotzdem "gesprochen" gemeldet.
|
||||
*/
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject): Boolean {
|
||||
val o = JSONObject()
|
||||
o.put("type", type)
|
||||
o.put("payload", payload)
|
||||
o.put("timestamp", System.currentTimeMillis())
|
||||
try { webSocket.send(o.toString()) } catch (_: Exception) {}
|
||||
return try { webSocket.send(o.toString()) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun sendHello(webSocket: WebSocket) {
|
||||
@@ -178,6 +187,31 @@ class RvsClient(
|
||||
|
||||
val action = payload.optString("action")
|
||||
val params = payload.optJSONObject("params") ?: JSONObject()
|
||||
|
||||
fun reply(r: JSONObject) {
|
||||
r.put("requestId", payload.optString("requestId"))
|
||||
r.put("hostId", config.hostId())
|
||||
r.put("action", action)
|
||||
send(webSocket, "host_result", r)
|
||||
}
|
||||
|
||||
// Alles, was SPRECHEN kann, laeuft in einem EIGENEN Thread. Es muss
|
||||
// abwarten, bis wirklich Ton kommt — und dieses handle() laeuft auf dem
|
||||
// Lese-Thread des WebSockets. Wuerden wir hier warten, kaeme das
|
||||
// erwartete audio_pcm nie an (wir blockieren ja selbst das Lesen) und
|
||||
// jede Sprachausgabe liefe garantiert in den Timeout.
|
||||
if (action in SPEAKING_ACTIONS && config.controlEnabled) {
|
||||
Thread {
|
||||
val r = try {
|
||||
if (action == "speak") doSpeak(params) else doNotify(params)
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
reply(r)
|
||||
}.also { it.isDaemon = true }.start()
|
||||
return
|
||||
}
|
||||
|
||||
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
|
||||
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
|
||||
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
|
||||
@@ -210,15 +244,16 @@ class RvsClient(
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
result.put("action", action)
|
||||
send(webSocket, "host_result", result)
|
||||
reply(result)
|
||||
}
|
||||
|
||||
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
|
||||
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
|
||||
|
||||
/** Aktionen, die sprechen koennen — laufen abseits des WS-Lese-Threads,
|
||||
* weil sie auf das eintreffende audio_pcm warten (siehe handle()). */
|
||||
private val SPEAKING_ACTIONS = setOf("speak", "notify")
|
||||
|
||||
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
|
||||
private val VOICE_TYPES = setOf(
|
||||
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
|
||||
@@ -368,7 +403,12 @@ class RvsClient(
|
||||
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
|
||||
if (p.optString("engine") == "device")
|
||||
return Speaker.speak(appCtx, text, focus)
|
||||
return runCatching { voice.requestTts(text); Unit }
|
||||
// Nicht nur abschicken, sondern abwarten bis wirklich Ton kommt —
|
||||
// sonst meldet der Agent Erfolg und der Fernseher bleibt stumm.
|
||||
return runCatching {
|
||||
val mid = voice.requestTts(text)
|
||||
voice.awaitSpeechStart(mid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doVolume(p: JSONObject): JSONObject {
|
||||
|
||||
@@ -33,7 +33,8 @@ import kotlin.random.Random
|
||||
class VoiceSession(
|
||||
private val appCtx: Context,
|
||||
private val config: AgentConfig,
|
||||
private val send: (type: String, payload: JSONObject) -> Unit,
|
||||
/** Gibt zurueck, ob der Socket die Nachricht angenommen hat (false = ins Leere). */
|
||||
private val send: (type: String, payload: JSONObject) -> Boolean,
|
||||
private val onEvent: (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
@@ -191,15 +192,39 @@ class VoiceSession(
|
||||
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
|
||||
}
|
||||
}
|
||||
send("tts_request", JSONObject().apply {
|
||||
val accepted = send("tts_request", JSONObject().apply {
|
||||
put("text", text)
|
||||
put("messageId", mid)
|
||||
put("voice", "")
|
||||
put("speed", 1.0)
|
||||
})
|
||||
if (!accepted) {
|
||||
expectedAudio.remove(mid)
|
||||
throw IllegalStateException(
|
||||
"Keine Verbindung zum Server — TTS-Anfrage ging nicht raus")
|
||||
}
|
||||
return mid
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet, bis fuer [mid] wirklich der erste Ton kommt.
|
||||
*
|
||||
* Ohne das meldete `host_speak` Erfolg, sobald die Anfrage im Socket lag —
|
||||
* ARIA sagte "erledigt", und der Fernseher blieb still. Hier wird erst
|
||||
* geantwortet, wenn tatsaechlich Audio laeuft, sonst mit klarem Grund.
|
||||
*/
|
||||
fun awaitSpeechStart(mid: String, timeoutMs: Long = 20000) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (playingMessageId == mid) return // Ton laeuft
|
||||
if (!expectedAudio.contains(mid)) return // schon fertig gesprochen
|
||||
try { Thread.sleep(100) } catch (_: InterruptedException) { return }
|
||||
}
|
||||
expectedAudio.remove(mid)
|
||||
throw IllegalStateException(
|
||||
"Server hat kein Audio geliefert (TTS-Dienst erreichbar? Stimme gesetzt?)")
|
||||
}
|
||||
|
||||
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
|
||||
fun ask(text: String): JSONObject {
|
||||
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
||||
|
||||
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.1.2"
|
||||
AGENT_VERSION = "0.0.1.4"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
@@ -100,9 +100,13 @@ function _parseToolCalls(text) {
|
||||
}
|
||||
|
||||
export function cliResultToOpenai(result, requestId) {
|
||||
const modelName = result.modelUsage
|
||||
? Object.keys(result.modelUsage)[0]
|
||||
: "claude-sonnet-4";
|
||||
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
|
||||
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
|
||||
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
|
||||
// zustande kam (z.B. unbekanntes Modell).
|
||||
const usage = result.modelUsage || {};
|
||||
const usedKey = Object.keys(usage)[0];
|
||||
const modelName = (usedKey && (usage[usedKey]?.canonicalModel || usedKey)) || "";
|
||||
|
||||
const rawText = result.result || "";
|
||||
const { tool_calls, rest } = _parseToolCalls(rawText);
|
||||
@@ -134,13 +138,32 @@ export function cliResultToOpenai(result, requestId) {
|
||||
total_tokens:
|
||||
(result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
|
||||
},
|
||||
// ARIA-Zusatzfelder (ausserhalb des OpenAI-Schemas, Clients ignorieren
|
||||
// sie). Damit kann die Diagnostic pruefen, ob ein Modell wirklich
|
||||
// gelaufen ist: bei einem unbekannten Namen kommt die CLI gar nicht bis
|
||||
// zum Call, dann ist modelUsage leer und is_error gesetzt. Ohne das
|
||||
// koennte man einen Fehlschlag nicht von einer Antwort unterscheiden —
|
||||
// die Fehlermeldung steht naemlich einfach im content.
|
||||
aria_model: modelName,
|
||||
aria_error: result.is_error === true,
|
||||
aria_context_window: (usedKey && usage[usedKey]?.contextWindow) || 0,
|
||||
aria_max_output: (usedKey && usage[usedKey]?.maxOutputTokens) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Modellname fuer die Antwort.
|
||||
*
|
||||
* Hier wurde der Name FRUEHER umgeschrieben: alles mit "opus" drin wurde zu
|
||||
* "claude-opus-4", Sonnet zu "claude-sonnet-4" usw. Damit log das Feld — die
|
||||
* CLI liefert in modelUsage den tatsaechlich benutzten Namen (z.B.
|
||||
* "claude-opus-5-5"), und genau der wurde weggeworfen. Im OpenAI-Protokoll
|
||||
* soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name
|
||||
* macht jede Ueberpruefung unmoeglich ("welches Modell lief gerade?").
|
||||
*
|
||||
* Jetzt bleibt der echte Name stehen; nur wenn gar keiner da ist, gibt es
|
||||
* einen neutralen Platzhalter.
|
||||
*/
|
||||
function normalizeModelName(model) {
|
||||
const m = model || "claude-sonnet-4";
|
||||
if (m.includes("opus")) return "claude-opus-4";
|
||||
if (m.includes("sonnet")) return "claude-sonnet-4";
|
||||
if (m.includes("haiku")) return "claude-haiku-4";
|
||||
return m;
|
||||
return model || "unknown";
|
||||
}
|
||||
|
||||
@@ -15,29 +15,47 @@
|
||||
* (siehe docker-compose.yml proxy-Block).
|
||||
*/
|
||||
|
||||
// NUR echte Tier-Aliase. Die CLI loest sie selbst auf die aktuelle Version des
|
||||
// Tiers auf.
|
||||
//
|
||||
// Hier standen frueher auch volle Versionen (claude-opus-5 → "opus",
|
||||
// claude-fable-5 → "fable", ...). Das war falsch: wer bewusst eine BESTIMMTE
|
||||
// Version waehlte, bekam still das Tier-Default — also unter Umstaenden ein
|
||||
// ganz anderes Modell als ausgewaehlt. Volle Namen gehen jetzt unveraendert an
|
||||
// die CLI (siehe extractModel), die versteht sie.
|
||||
const MODEL_MAP = {
|
||||
"claude-opus-4": "opus",
|
||||
"claude-sonnet-4": "sonnet",
|
||||
"claude-haiku-4": "haiku",
|
||||
"claude-code-cli/claude-opus-4": "opus",
|
||||
"claude-code-cli/claude-sonnet-4": "sonnet",
|
||||
"claude-code-cli/claude-haiku-4": "haiku",
|
||||
"opus": "opus",
|
||||
"sonnet": "sonnet",
|
||||
"haiku": "haiku",
|
||||
"fable": "fable",
|
||||
"claude-fable-5": "fable",
|
||||
"claude-code-cli/opus": "opus",
|
||||
"claude-code-cli/sonnet": "sonnet",
|
||||
"claude-code-cli/haiku": "haiku",
|
||||
"claude-code-cli/fable": "fable",
|
||||
// Volle aktuelle IDs (falls die App/Diagnostic sie mal direkt setzt)
|
||||
"claude-opus-5": "opus",
|
||||
"claude-sonnet-5": "sonnet",
|
||||
"claude-haiku-4-5": "haiku",
|
||||
};
|
||||
|
||||
// Echte Claude-Modellnamen: claude-<familie>-<zahlen/suffixe>.
|
||||
// Die CLI nimmt laut `claude --help` "an alias for the latest model (e.g.
|
||||
// 'fable', 'opus', or 'sonnet') or a model's full name" — exakte Versionen
|
||||
// darf man also direkt durchreichen.
|
||||
const FULL_MODEL_RE = /^claude-[a-z]+-[0-9][0-9a-z.-]*$/;
|
||||
|
||||
export function extractModel(model) {
|
||||
if (MODEL_MAP[model]) return MODEL_MAP[model];
|
||||
const stripped = (model || "").replace(/^claude-code-cli\//, "");
|
||||
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
|
||||
// Exakte Version durchreichen statt sie auf einen Tier einzuschmelzen.
|
||||
//
|
||||
// Vorher landete JEDER unbekannte Name still auf "opus". Damit war eine
|
||||
// neuere Version faktisch nicht waehlbar: wer "claude-opus-5-5" setzte,
|
||||
// bekam ohne jede Meldung das Tier-Default — die Einstellung sah gesetzt
|
||||
// aus und tat nichts. Kennt die CLI den Namen nicht, sagt SIE das deutlich
|
||||
// ("isn't described by this version's model catalog"), und das ist die
|
||||
// ehrlichere Antwort als ein stilles Downgrade.
|
||||
if (FULL_MODEL_RE.test(stripped)) return stripped;
|
||||
if (stripped) {
|
||||
console.error(`[aria-model] Unbekannter Modellname ${JSON.stringify(stripped)} — nutze 'opus'`);
|
||||
}
|
||||
return "opus";
|
||||
}
|
||||
|
||||
|
||||
+131
-6
@@ -552,19 +552,144 @@ function _loadModels() {
|
||||
return DEFAULT_MODELS;
|
||||
}
|
||||
|
||||
export function handleModels(_req, res) {
|
||||
// ── Echte Modell-Liste von Anthropic ────────────────────────────────
|
||||
//
|
||||
// Anthropic veroeffentlicht einen signierten Modell-Katalog, aus dem auch
|
||||
// Claude Code selbst seinen Modell-Auswaehler fuellt. Oeffentlich erreichbar,
|
||||
// KEIN API-Key noetig (die Models-API /v1/models braucht einen; mit dem
|
||||
// Max-Abo gibt es keinen).
|
||||
//
|
||||
// Warum ueberhaupt: die handgepflegte Liste hier veraltete garantiert. Sie
|
||||
// kannte Opus 5 — waehrend im Katalog laengst Opus 5.5, Sonnet 5.5 und
|
||||
// Fable 5.1 standen. Ein "↻ Aktualisieren"-Knopf, der nur eine lokale Datei
|
||||
// neu liest, aktualisiert nichts.
|
||||
//
|
||||
// Struktur: surfaces.<surface>.model_selector_config[].models[]
|
||||
// = { id, name, short_name, description, section }
|
||||
// 'cc' ist die Claude-Code-Oberflaeche — genau die, durch die ARIA laeuft.
|
||||
// section 'main' = die vorgeschlagenen, 'overflow' = aeltere/weitere.
|
||||
const CATALOG_URL = process.env.ARIA_MODEL_CATALOG_URL
|
||||
|| "https://downloads.claude.ai/model-catalog/v1/catalog.json";
|
||||
const CATALOG_CACHE_FILE = "/shared/config/model_catalog_cache.json";
|
||||
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h — der Katalog gilt eine Woche
|
||||
const CATALOG_SURFACE = "cc";
|
||||
|
||||
let _catalogMem = null; // { at, models }
|
||||
|
||||
function _parseCatalog(doc) {
|
||||
const out = [];
|
||||
const surfaces = (doc && doc.surfaces && doc.surfaces[CATALOG_SURFACE]) || null;
|
||||
const configs = surfaces && surfaces.model_selector_config;
|
||||
if (!Array.isArray(configs)) return out;
|
||||
for (const cfg of configs) {
|
||||
for (const m of (cfg.models || [])) {
|
||||
if (!m || typeof m.id !== "string") continue;
|
||||
out.push({
|
||||
id: m.id,
|
||||
tier: m.short_name ? String(m.short_name).toLowerCase() : m.id,
|
||||
display_name: m.name || m.id,
|
||||
description: m.description || "",
|
||||
section: m.section || "main",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _readCatalogCache() {
|
||||
try {
|
||||
const c = JSON.parse(fs.readFileSync(CATALOG_CACHE_FILE, "utf-8"));
|
||||
if (Array.isArray(c.models) && c.models.length) return c;
|
||||
} catch (_) { /* kein Cache */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Katalog-Modelle. `force` umgeht den TTL (Diagnostic-Knopf).
|
||||
* Schlaegt der Abruf fehl, gilt der Cache — auch ein abgelaufener, denn eine
|
||||
* etwas alte echte Liste ist besser als gar keine.
|
||||
*/
|
||||
async function _catalogModels(force) {
|
||||
const now = Date.now();
|
||||
if (!force && _catalogMem && (now - _catalogMem.at) < CATALOG_TTL_MS) {
|
||||
return _catalogMem.models;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = _readCatalogCache();
|
||||
if (cached && (now - (cached.at || 0)) < CATALOG_TTL_MS) {
|
||||
_catalogMem = { at: cached.at, models: cached.models };
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ctl = AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined;
|
||||
const r = await fetch(CATALOG_URL, { signal: ctl });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const doc = await r.json();
|
||||
const models = _parseCatalog(doc);
|
||||
if (!models.length) throw new Error("Katalog enthaelt keine Modelle fuer Surface " + CATALOG_SURFACE);
|
||||
_catalogMem = { at: now, models };
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync(CATALOG_CACHE_FILE, JSON.stringify({
|
||||
at: now, version: doc.version, expires_at: doc.expires_at, models,
|
||||
}, null, 2));
|
||||
} catch (e) {
|
||||
console.error("[aria-models] Katalog-Cache schreiben fehlgeschlagen:", e && e.message);
|
||||
}
|
||||
console.error(`[aria-models] Katalog geladen: ${models.length} Modelle (Version ${doc.version})`);
|
||||
return models;
|
||||
} catch (e) {
|
||||
console.error("[aria-models] Katalog-Abruf fehlgeschlagen:", e && e.message);
|
||||
const cached = _readCatalogCache();
|
||||
if (cached) {
|
||||
console.error("[aria-models] nutze Cache vom " + new Date(cached.at || 0).toISOString());
|
||||
_catalogMem = { at: cached.at, models: cached.models };
|
||||
return cached.models;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleModels(req, res) {
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const models = _loadModels();
|
||||
const force = !!(req && req.query && (req.query.refresh === "1" || req.query.refresh === "true"));
|
||||
|
||||
// 1) Tier-Aliase zuerst: sie zeigen IMMER auf die aktuelle Version ihres
|
||||
// Tiers, ganz ohne Pflege. Fuer den Alltag die richtige Wahl.
|
||||
const tiers = _loadModels().map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier || m.id,
|
||||
display_name: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
section: "tier",
|
||||
}));
|
||||
|
||||
// 2) Danach die exakten Versionen aus dem Anthropic-Katalog.
|
||||
const catalog = await _catalogModels(force);
|
||||
const seen = new Set(tiers.map(t => t.id));
|
||||
const exact = catalog.filter(m => !seen.has(m.id)).map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier,
|
||||
display_name: m.display_name
|
||||
+ (m.section === "main" ? "" : " (ältere Version)"),
|
||||
description: m.description
|
||||
|| (m.section === "main" ? "" : "Aeltere Version — nur waehlen wenn gewollt."),
|
||||
section: m.section,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
object: "list",
|
||||
data: models.map(m => ({
|
||||
catalog_ok: catalog.length > 0,
|
||||
data: [...tiers, ...exact].map(m => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
owned_by: "anthropic",
|
||||
created,
|
||||
tier: m.tier || m.id,
|
||||
display_name: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
tier: m.tier,
|
||||
display_name: m.display_name,
|
||||
description: m.description,
|
||||
section: m.section,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
+85
-26
@@ -99,6 +99,12 @@ STREAM_SPEAKER_CHECK_MS = 1500 # Mindest-Audio fuer Speaker-ID-Pruefung
|
||||
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
|
||||
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
|
||||
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
||||
# Zwischen-Transkriptionen sehen nur die letzten N Sekunden an. Sonst waechst
|
||||
# ihre Rechenzeit mit der Aufnahmelaenge (jedes Mal der ganze Buffer): bei 2min
|
||||
# Sprechen dauerte eine Zwischen-Transkription mehrere Sekunden, blockierte die
|
||||
# GPU und machte die Live-Anzeige traege. Der FINALE Text entsteht weiterhin aus
|
||||
# dem gesamten Buffer (beam_size=5) — es geht also kein Wort verloren.
|
||||
STREAM_PARTIAL_WINDOW_MS = 30000
|
||||
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
||||
|
||||
# Akustisches Endpointing (ergaenzt die rein-semantische Stagnation).
|
||||
@@ -387,6 +393,10 @@ class StreamSession:
|
||||
speaker_checked: bool = False
|
||||
speaker_match: Optional[bool] = None
|
||||
speaker_similarity: float = 0.0
|
||||
# Laufende Zwischen-Transkription. Die laeuft NEBENHER, nicht im Tick —
|
||||
# sonst stand der Endpointer waehrend des Transkribierens, und die
|
||||
# Rechenzeit wurde als Stille gezaehlt (siehe _tick_session).
|
||||
partial_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
class SessionManager:
|
||||
@@ -479,7 +489,11 @@ class SessionManager:
|
||||
return session
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
self._sessions.pop(request_id, None)
|
||||
sess = self._sessions.pop(request_id, None)
|
||||
# Nicht als Waise weiterlaufen lassen — sie haelt sonst den GPU-Lock
|
||||
# fuer eine Sitzung, die es nicht mehr gibt.
|
||||
if sess is not None and sess.partial_task is not None and not sess.partial_task.done():
|
||||
sess.partial_task.cancel()
|
||||
|
||||
async def run_endpointer(self) -> None:
|
||||
"""Background-Loop: alle ~200ms ueber alle Sessions iterieren."""
|
||||
@@ -660,38 +674,78 @@ class SessionManager:
|
||||
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
|
||||
return
|
||||
|
||||
# Zwischen-Transkription NEBENHER starten, nicht hier abwarten.
|
||||
#
|
||||
# WARUM: dieser Tick laeuft alle 200ms und ist die einzige Stelle, die
|
||||
# akustische Sprach-Energie misst (last_voice_at). Wurde hier auf das
|
||||
# Transkribieren gewartet, stand der Endpointer solange — und beim
|
||||
# naechsten Tick war `now - last_voice_at` um die gesamte Rechenzeit
|
||||
# groesser. Die Rechenzeit zaehlte also als STILLE.
|
||||
#
|
||||
# Das skaliert mit der Aufnahmelaenge, weil jede Zwischen-Transkription
|
||||
# den KOMPLETTEN Buffer neu durchlaeuft: bei 10s Audio harmlos, bei 2min
|
||||
# dauert sie mehrere Sekunden und reisst damit die endpoint_ms-Grenze —
|
||||
# mitten im Satz. Genau das war "lange Texte hoeren irgendwann auf,
|
||||
# obwohl ich weiterrede".
|
||||
if sess.partial_task is not None and not sess.partial_task.done():
|
||||
return # vorige laeuft noch — Tick bleibt frei fuers Messen
|
||||
sess.last_transcribe_at = now
|
||||
try:
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
except Exception:
|
||||
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
|
||||
return
|
||||
sess.partial_task = asyncio.create_task(self._partial_transcribe(sess, ws))
|
||||
|
||||
async def _partial_transcribe(self, sess: StreamSession, ws) -> None:
|
||||
"""Zwischen-Transkription fuer Live-Text + Wachstums-Erkennung.
|
||||
|
||||
Laeuft als eigener Task (siehe _tick_session). Der finale Text entsteht
|
||||
in _finalize aus dem GESAMTEN Buffer mit beam_size=5 — hier darf also
|
||||
gekuerzt und ungenau gearbeitet werden, ohne dass Text verloren geht.
|
||||
"""
|
||||
try:
|
||||
# Nur das Ende des Buffers ansehen. Sonst waechst die Rechenzeit mit
|
||||
# der Aufnahmelaenge, bindet die GPU und die Live-Anzeige wird immer
|
||||
# traeger. Fuer "waechst der Text noch?" reicht der Schluss.
|
||||
tail_bytes = int(STREAM_PARTIAL_WINDOW_MS / 1000.0 * sess.sample_rate) * 2
|
||||
buf = bytes(sess.pcm_buffer)
|
||||
windowed = len(buf) > tail_bytes
|
||||
try:
|
||||
audio = pcm_s16le_to_float32(buf[-tail_bytes:] if windowed else buf)
|
||||
except Exception:
|
||||
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
|
||||
return
|
||||
|
||||
# Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz,
|
||||
# nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt
|
||||
# dann mit beam_size=5.
|
||||
text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True)
|
||||
except Exception:
|
||||
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
|
||||
return
|
||||
try:
|
||||
text, _dur = await self.runner.transcribe(
|
||||
audio, sess.language, beam_size=1, vad_filter=True)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
|
||||
return
|
||||
|
||||
text = text.strip()
|
||||
grew = bool(text) and text != sess.last_partial
|
||||
if grew:
|
||||
sess.last_partial = text
|
||||
sess.last_growth_at = now
|
||||
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
|
||||
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
|
||||
await _send(ws, "stt_partial", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": text,
|
||||
})
|
||||
await _debug_log(ws, "stream.partial",
|
||||
f"id={sess.request_id[:12]} text={text[:80]!r}")
|
||||
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
|
||||
# semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
|
||||
if sess.endpoint_sent or sess.closed:
|
||||
return # Session ist durch — Partial nicht mehr nachschicken
|
||||
text = text.strip()
|
||||
grew = bool(text) and text != sess.last_partial
|
||||
if grew:
|
||||
sess.last_partial = text
|
||||
sess.last_growth_at = time.time()
|
||||
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
|
||||
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
|
||||
await _send(ws, "stt_partial", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": text,
|
||||
# Nur der Schluss der Aufnahme — der volle Text kommt final.
|
||||
"windowed": windowed,
|
||||
})
|
||||
await _debug_log(ws, "stream.partial",
|
||||
f"id={sess.request_id[:12]}{' [tail]' if windowed else ''} text={text[:80]!r}")
|
||||
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
|
||||
# semantischer Backstop) laeuft im Tick, hier nichts mehr zu tun.
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _buffer_duration_ms(self, sess: StreamSession) -> float:
|
||||
# 16-bit s16le mono → 2 bytes pro Sample
|
||||
@@ -743,6 +797,11 @@ class SessionManager:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
# Laufende Zwischen-Transkription abbrechen: sie haelt den GPU-Lock und
|
||||
# der finale Text muesste sonst hinter ihr warten — bei langen Aufnahmen
|
||||
# mehrere Sekunden zusaetzliche Verzoegerung fuer nichts.
|
||||
if sess.partial_task is not None and not sess.partial_task.done():
|
||||
sess.partial_task.cancel()
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
if audio.size == 0:
|
||||
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
||||
|
||||
Reference in New Issue
Block a user