Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761217cb5a | ||
|
|
ff6d31acbd | ||
|
|
2d7a5784c2 | ||
|
|
fa871219ae | ||
|
|
e61a0ff871 | ||
|
|
c9a1d80696 | ||
|
|
8a0670a3d2 | ||
|
|
2d9c42a7ea | ||
|
|
ff1205eb6b | ||
|
|
d12f67320b | ||
|
|
24a1b4d837 | ||
|
|
db96258f7d | ||
|
|
becc83d9e3 | ||
|
|
44220edbd1 | ||
|
|
fc13957000 | ||
|
|
60580fd67e | ||
|
|
2d83e4ad8b | ||
|
|
629a3d82ac | ||
|
|
9bb90a777a | ||
|
|
c3bb7985a2 | ||
|
|
debd96b16f | ||
|
|
486d7dedbb | ||
|
|
d5bcbb4814 | ||
|
|
54ebd57990 | ||
|
|
fb06ed928c | ||
|
|
08bb257791 | ||
|
|
44982a9b3c | ||
|
|
ddb1bfac6a | ||
|
|
4605698b02 | ||
|
|
1c11cb6a2f |
@@ -10,6 +10,90 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Nachrichten-Queue pro Projekt (App + Diagnostic)**
|
||||||
|
- Eine zweite Nachricht, während ARIA am aktuellen Task arbeitet, wird jetzt **angestellt** statt den laufenden Task abzubrechen (vorher: Barge-In-Cancel). Sie läuft der Reihe nach, **pro Projekt unabhängig** (paralleles Arbeiten in mehreren Projekten bleibt). Wartende Nachrichten zeigen als ⏸-Bubble — tippen entfernt sie aus der Warteschlange.
|
||||||
|
- **Rückfrage-Loop:** Stellt ARIA eine echte, blockierende Rückfrage, **pausiert** die Queue und deine nächste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann läuft der nächste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". Der Stop-Button bricht den aktuellen Task ab und schaltet zum nächsten.
|
||||||
|
- ARIA signalisiert eine Rückfrage über einen **unsichtbaren `[[AWAIT]]`-Marker** — wie speak/converse deklariert das Modell den Zustand selbst (kein „endet-mit-?"-Raten). Brain strippt ihn, gibt `awaiting_reply` durch `chat()` → `ChatOut` → Bridge-Chat-Payload. Local (tool-los) und Fast-Path markieren nie.
|
||||||
|
|
||||||
|
**Pro-Projekt-Textfeld-Entwürfe (App + Diagnostic)**
|
||||||
|
- Der Feldinhalt bleibt beim Projektwechsel erhalten: in Projekt X tippen, zu Y wechseln (leeres Feld), zurück zu X → dein Entwurf steht wieder da. In Storage persistiert.
|
||||||
|
|
||||||
|
**TTS-Abspiel-Queue (App)**
|
||||||
|
- Zwei fast gleichzeitig fertige Antworten sprechen jetzt garantiert **nacheinander** statt sich gegenseitig abzuschneiden. Vorher war das Timing-Glück (`PcmStreamPlayer.start()` ruft `stopInternal()` = flush/release, hätte die laufende gecuttet). Jetzt: „spielt hörbar" gilt bis zum echten `PcmPlaybackFinished` (nicht nur bis Stream-Ende); eine neue hörbare Antwort, die währenddessen ankommt, wird gepuffert und danach nachgespielt (Kette für 3, 4, …). Harter Stop/Barge-In/Mund-Button verwirft die Queue.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Voice bricht nicht mehr ab:** eine neue Sprachnachricht während ARIA arbeitet stoppt nur akustisch das TTS (sauberes Mikro) und wird über den Brain-Projekt-Lock serialisiert, statt den laufenden Task abzubrechen (passend zu „immer anstellen + Stop-Button"). Text-Senden erkennt Brain-busy als Fallback, damit auch nach einem voice-gestarteten Turn korrekt angestellt wird. Grenze: eine per Sprache gestartete Aufgabe erscheint nicht als löschbare ⏸-Bubble (Aufnahme wird live gestreamt, nicht app-seitig gepuffert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.1.4] — 2026-07-12 — Lokales LLM: der ehrliche Rückbau
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
**Lokales LLM wieder tool-los (B1a) — ein 8B ist ein schlechter Tool-Caller**
|
||||||
|
- B1b hatte dem lokalen Modell Werkzeuge (`run_*`/`web_search`) gegeben — die gemeinsame Wurzel von **zwei** Problemen: (1) ein 8B erfindet mit Werkzeug in der Hand lieber eine plausible Antwort („der Song ist X") statt es zu rufen → Halluzination; (2) das erzwang per-Skill-Guards (skaliert nicht). Local ist jetzt wieder **tool-los** = reines Reden; alles mit Grundwahrheit (Fakt/Live-Zustand/Gedächtnis/Aktion) gehört an Claude oder den deterministischen Fast-Path. Kein Skill-Ergebnis mehr fälschbar
|
||||||
|
- **Keine Input-Wortliste im Router:** eine kurz eingeführte `_LIVE_HINTS`-Blacklist (Wetter/Musik/… → Claude) wieder entfernt — aus offenem Freitext die Absicht per Wortliste zu raten ist nie vollständig, jeder Miss = ein Halo (nur von per-Skill auf per-Wort verschoben). Generisch = das Modell entscheidet **selbst** (`<<ESCALATE>>`); ein stärkeres lokales Modell übernimmt die Selbst-Erkennung später, bis dahin ist local per Einstellung abschaltbar (aus, nicht raus)
|
||||||
|
- Expliziter Nutzer-Wunsch „nimm Claude/Clodi" wird im Router respektiert (geht nie lokal)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Info-Halluzination:** local nannte manchmal aktuellen Song/Restzeit/Skip-Titel ohne `run_spotify` zu rufen (mal echt, mal frei erfunden — „Midnight City von M83" nie aufgerufen). Neuer Output-Guard `_claims_live_media_state` eskaliert behauptete Live-Auskünfte ohne echten Skill-Call an Claude; Local-Prompt zusätzlich gehärtet (nie Titel/Zeit/Gerät ohne Tool-Ergebnis; bei „OK: next" keinen Titel erfinden)
|
||||||
|
- **Leeres `<voice></voice>` machte TTS stumm:** Claude hängt reflexartig manchmal ein leeres Voice-Tag an → `clean_text_for_tts` nahm den leeren Inhalt → gar keine Sprachausgabe (Playlist „Fliegen" gesprochen, „Prodigy" stumm — reiner Claude-Output-Zufall, nicht Skill/Playlist-Name). Leeres/whitespace-Tag wird jetzt ignoriert, der normale Anzeigetext gelesen
|
||||||
|
- **Datei-Anhang erschien erst nach Seitenwechsel:** die Live-`chat`-Payload trug keine `files` (Anhänge kamen nur als separates `file_from_aria`-Event) → an der Nachricht tauchte die Datei erst nach Reload aus `chat_backup` auf. Bridge schickt die `files` jetzt in der chat-Payload, App hängt sie live an die Text-Bubble (wie der Reload-Pfad) und entfernt die redundante Solo-Bubble
|
||||||
|
- **TDZ-Zeitbombe (App):** `sendTextMessage` stand vor seinen Dependencies (`interruptAriaIfBusy`, `sendPendingAttachments`) im deps-Array — Temporal Dead Zone; lief nur dank Babels `const`→`var`-Hebung, ein strengerer Bundler hätte beim Mount weißgescreent. Deklaration hinter die Deps verschoben
|
||||||
|
- **QRScanner tsc-clean:** toter Prop `colorForScannerFrame` (existiert in `react-native-camera-kit` v13 nicht) entfernt; die fehlerhaften Lib-Typen (optionale Props als required markiert) lokal + dokumentiert umgangen → **Projekt komplett tsc-clean (0 Fehler)**
|
||||||
|
|
||||||
|
**Spotify-Skill — von ARIA live im Gespräch weiter geschärft**
|
||||||
|
- Skip (`next`/`previous`) sagt jetzt den **echten** neuen Titel an (holt den Track nach dem Skip via API) statt local einen erfinden zu lassen
|
||||||
|
- `playlist_play`/`search_and_play`/`play` nennen das **tatsächliche** Wiedergabegerät (aus `GET /v1/me/player`, kein Raten — verhinderte den Fehler, dass Claude ein falsch geratenes Gerät auch noch ansteuerte) und lesen konsistent vor; saubere „Sprach-Grammatik": Ansagen sprechen, Steuerbefehle (play/pause/transfer/volume) schweigen
|
||||||
|
- `yt-dlp-download`-Skill um einen MP3-Modus erweitert — ARIA hat das fehlende Werkzeug **selbst gebaut**, als eine deutsche Titelmelodie nicht auf Spotify lag (Web-Suche → YouTube-Download → MP3 in den Chat, in <1 min)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.1.3] — 2026-07-11
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **`skill_get`-Tool:** ARIA liest den echten Quellcode + Manifest + Readme eines Skills, **bevor** sie ihn ändert — kein Blind-Rewrite mehr (vorher wurde ein guter Skill durch eine schlechtere Neufassung ersetzt, weil das referenzierte `skill_get` gar nicht existierte)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **`converse` folgt dem Skill (Fast-Path):** auch ein Fast-Path-Befehl kann einen Skill auslösen, nach dem noch etwas zu sagen ist — `converse` kommt jetzt aus Manifest/Skill-Output statt hart auf `False`
|
||||||
|
- **Sprachnachricht-Bubble verschwand nach manuellem Stop:** bei „ohne Ohr" aufgenommener Sprachnachricht + Stop entfernte ein leeres stream-end-Endpoint die schon gefüllte Bubble; jetzt wird nur noch der unaufgelöste Platzhalter entfernt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.1.2] — 2026-07-11 — Standort-Intelligenz + Skill steuert seine Ausgabe
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**GPS → Ortsname im Standort-Präfix (keyless, keine Tokens)**
|
||||||
|
- Reverse-Geocoding der Koordinaten in der Bridge (Nominatim zoom=18: Straße+Hausnr, PLZ+Ort, Bundesland; Straßen-Ref + Autobahn-km via Overpass) — damit das lokale Modell nicht „Berlin" für Oldenburg rät; Ortsname wird **vor** dem Präfix-Bau awaited (rechtzeitig für die erste Nachricht)
|
||||||
|
- Fahrtrichtung als Himmelsrichtung **+ exakte Peilung in Grad** (Haversine/Bearing aus aufeinanderfolgenden Fixes, `MIN_MOVE_M`-Schwelle gegen Zittern)
|
||||||
|
|
||||||
|
**Skill steuert seine Ausgabe selbst — `speak` + `converse` pro Aufruf**
|
||||||
|
- Ein Skill entscheidet per JSON-Output `{speak, converse}` pro Operation, ob vorgelesen wird und ob danach 30 s weitergelauscht wird (Manifest-Default, Output überschreibt) — z. B. „was läuft" vorlesen aber kein Dialog, „nächstes Lied" stumm. In der Skill-Bauanleitung **mit dem WARUM** dokumentiert, damit die KI die Flags beim Bauen versteht (kein Hardcode im Brain)
|
||||||
|
|
||||||
|
### Behoben / Geändert
|
||||||
|
|
||||||
|
- **Generischer Skill-Prompt (kein Hardcode):** der Router beschreibt `run_*`-Skills generisch (weiß nicht mehr, welche „schwer" sind); ein Stop im 30-s-Lauschen beendet dieses jetzt wirklich (kein zweiter Gong / erneutes Öffnen)
|
||||||
|
- **Anti-Halluzination:** behauptet local eine Steuerbefehl-Quittung („Spotify: …", „Playlist abspielen") ohne das Tool wirklich zu rufen → Eskalation an Claude statt erfundene Bestätigung durchzulassen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.1.1] — 2026-07-11
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Skill entscheidet selbst, ob vorgelesen wird (`manifest.speak`):** Grundlage der späteren `speak`/`converse`-Architektur — der `speak`-Flag greift sowohl im lokalen als auch im Claude-Pfad, statt am fragilen leeren `<voice></voice>`-Hack zu hängen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.0.6] — 2026-07-11
|
## [0.2.0.6] — 2026-07-11
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -474,7 +474,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
||||||
- **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.
|
- **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)
|
- **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)
|
||||||
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
|
- **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
|
||||||
|
- **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
|
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||||
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
||||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||||
@@ -482,6 +484,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||||
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||||
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||||
|
- **TTS-Abspiel-Queue**: Zwei fast gleichzeitig fertige Antworten sprechen garantiert **nacheinander** statt sich abzuschneiden — eine neue hoerbare Antwort, die waehrend der Wiedergabe einer anderen ankommt, wird gepuffert und erst nach deren echtem Wiedergabe-Ende (`PcmPlaybackFinished`, nicht nur Stream-Ende) nachgespielt. Harter Stop / Barge-In / Mund-Button verwirft die Queue
|
||||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||||
@@ -994,6 +997,9 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
|
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
|
||||||
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
|
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
|
||||||
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
|
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
|
||||||
|
- [x] **Pro-Projekt-Nachrichten-Queue** (loest das alte Barge-In-Cancel ab): zweite Nachricht wird **angestellt** statt den laufenden Task abzubrechen; **Rueckfrage-Loop** via unsichtbarem `[[AWAIT]]`-Marker (Queue pausiert, naechste Eingabe beantwortet die Rueckfrage); Stop-Button schaltet zum naechsten; sichtbare `⏸`-Bubbles (loeschbar). Pro Projekt unabhaengig, App + Diagnostic
|
||||||
|
- [x] Pro-Projekt-Textfeld-Entwuerfe (Feldinhalt bleibt beim Projektwechsel erhalten, persistiert; App + Diagnostic)
|
||||||
|
- [x] **TTS-Abspiel-Queue**: zwei fast gleichzeitig fertige Antworten sprechen garantiert nacheinander statt sich abzuschneiden (Puffern bis `PcmPlaybackFinished` der laufenden)
|
||||||
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
|
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
|
||||||
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
|
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
|
||||||
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20101
|
versionCode 20106
|
||||||
versionName "0.2.1.1"
|
versionName "0.2.1.6"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.1.1",
|
"version": "0.2.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{hasPermission ? (
|
{hasPermission ? (
|
||||||
<>
|
<>
|
||||||
|
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
|
||||||
|
CameraScreen-Props faelschlich als required (defaultProps fuellen
|
||||||
|
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
|
||||||
|
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
|
||||||
|
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
|
||||||
|
zu umgehen, ohne echten Code zu veraendern. */}
|
||||||
<CameraScreen
|
<CameraScreen
|
||||||
scanBarcode={true}
|
{...({
|
||||||
onReadCode={handleBarcodeScan}
|
scanBarcode: true,
|
||||||
showFrame={true}
|
onReadCode: handleBarcodeScan,
|
||||||
frameColor="#0096FF"
|
showFrame: true,
|
||||||
laserColor="#0096FF"
|
frameColor: '#0096FF',
|
||||||
colorForScannerFrame="#0096FF"
|
laserColor: '#0096FF',
|
||||||
|
} as any)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay oben */}
|
{/* Overlay oben */}
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ interface Attachment {
|
|||||||
deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager)
|
deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pro-Projekt-Queue-Zustand: idle = nichts laeuft; running = ARIA arbeitet am
|
||||||
|
// aktuellen Task; awaiting_reply = ARIA hat eine blockierende Rueckfrage gestellt,
|
||||||
|
// die naechste Eingabe beantwortet sie (statt einen neuen Auftrag anzustellen).
|
||||||
|
type CtxState = 'idle' | 'running' | 'awaiting_reply';
|
||||||
|
|
||||||
|
// Wartender Queue-Eintrag (id = die Bubble-ID der pending_queue-Nachricht).
|
||||||
|
interface QueuedItem { id: string; text: string; }
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
sender: 'user' | 'aria';
|
sender: 'user' | 'aria';
|
||||||
@@ -131,7 +139,7 @@ interface ChatMessage {
|
|||||||
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
|
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
|
||||||
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
|
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
|
||||||
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
|
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
|
||||||
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
|
deliveryStatus?: 'queued' | 'pending_queue' | 'sending' | 'sent' | 'delivered' | 'failed';
|
||||||
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
|
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
|
||||||
sendAttempts?: number;
|
sendAttempts?: number;
|
||||||
}
|
}
|
||||||
@@ -275,6 +283,7 @@ async function checkFileExists(uri: string): Promise<boolean> {
|
|||||||
const ChatScreen: React.FC = () => {
|
const ChatScreen: React.FC = () => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
|
const inputTextRef = useRef('');
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||||
const [showFileUpload, setShowFileUpload] = useState(false);
|
const [showFileUpload, setShowFileUpload] = useState(false);
|
||||||
const [showCameraUpload, setShowCameraUpload] = useState(false);
|
const [showCameraUpload, setShowCameraUpload] = useState(false);
|
||||||
@@ -301,6 +310,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
|
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
|
||||||
// Busy-Status des fokussierten Kontexts ohne stale Closure).
|
// Busy-Status des fokussierten Kontexts ohne stale Closure).
|
||||||
const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
|
const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
|
||||||
|
// ── Pro-Projekt-Nachrichten-Queue + Zustandsautomat (app-lokal) ──
|
||||||
|
// Key = projectId ('' = Hauptchat). state pro Kontext + Queue der wartenden
|
||||||
|
// User-Bubbles. Sendet man waehrend 'running', wird angestellt statt
|
||||||
|
// abgebrochen; 'awaiting_reply' leitet die naechste Eingabe als Antwort weiter.
|
||||||
|
const [projectStates, setProjectStates] = useState<Record<string, CtxState>>({});
|
||||||
|
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
|
||||||
|
const projectStatesRef = useRef<Record<string, CtxState>>({});
|
||||||
|
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
|
||||||
|
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
|
||||||
|
const projectDraftsRef = useRef<Record<string, string>>({});
|
||||||
|
const prevFocusedPidRef = useRef<string>('');
|
||||||
|
// Ref-Bruecke, damit der frueh deklarierte RVS-Message-Handler den erst spaeter
|
||||||
|
// deklarierten Queue-Automaten aufrufen kann (ohne TDZ / stale Closure).
|
||||||
|
const queueApiRef = useRef<{
|
||||||
|
getCtxState: (pid: string) => CtxState;
|
||||||
|
setCtxState: (pid: string, s: CtxState) => void;
|
||||||
|
advanceQueue: (pid: string) => void;
|
||||||
|
} | null>(null);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
@@ -338,6 +365,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
|
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
|
||||||
// oder AsyncStorage-Load nicht beruecksichtigt).
|
// oder AsyncStorage-Load nicht beruecksichtigt).
|
||||||
const ttsCanPlayRef = useRef<boolean>(true);
|
const ttsCanPlayRef = useRef<boolean>(true);
|
||||||
|
// Letzte Antwort: nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
||||||
|
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
||||||
|
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
||||||
|
const converseRef = useRef<boolean>(true);
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
@@ -505,14 +536,39 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
|
// Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln.
|
||||||
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
|
|
||||||
// fuer den Auto-Fall angenehm.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
|
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
|
||||||
focusedProjectIdRef.current = focusedProjectId;
|
focusedProjectIdRef.current = focusedProjectId;
|
||||||
|
// Draft-Wechsel: der aktuelle Feldinhalt gehoert dem VERLASSENEN Projekt,
|
||||||
|
// der Entwurf des neuen Projekts wird geladen (leer = leeres Feld).
|
||||||
|
const prevPid = prevFocusedPidRef.current;
|
||||||
|
if (prevPid !== focusedProjectId) {
|
||||||
|
projectDraftsRef.current = { ...projectDraftsRef.current, [prevPid]: inputTextRef.current };
|
||||||
|
setInputText(projectDraftsRef.current[focusedProjectId] || '');
|
||||||
|
prevFocusedPidRef.current = focusedProjectId;
|
||||||
|
AsyncStorage.setItem('aria_project_drafts', JSON.stringify(projectDraftsRef.current)).catch(() => {});
|
||||||
|
}
|
||||||
}, [focusedProjectId]);
|
}, [focusedProjectId]);
|
||||||
|
|
||||||
|
// inputText-Spiegel (damit der Draft-Wechsel oben inputText nicht als Dep braucht).
|
||||||
|
useEffect(() => { inputTextRef.current = inputText; }, [inputText]);
|
||||||
|
|
||||||
|
// Drafts beim Start aus Storage laden.
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem('aria_project_drafts').then(v => {
|
||||||
|
if (!v) return;
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(v);
|
||||||
|
if (map && typeof map === 'object') {
|
||||||
|
projectDraftsRef.current = map;
|
||||||
|
const cur = map[focusedProjectIdRef.current] || '';
|
||||||
|
if (cur) setInputText(cur);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
|
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
|
||||||
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
|
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
|
||||||
// die sich bei jedem Wechsel).
|
// die sich bei jedem Wechsel).
|
||||||
@@ -990,6 +1046,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
||||||
if (message.type === 'file_from_aria') {
|
if (message.type === 'file_from_aria') {
|
||||||
const p = message.payload || {};
|
const p = message.payload || {};
|
||||||
|
const sp = (p.serverPath as string) || '';
|
||||||
const ariaMsg: ChatMessage = {
|
const ariaMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
@@ -1000,10 +1057,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
name: (p.name as string) || 'datei',
|
name: (p.name as string) || 'datei',
|
||||||
size: (p.size as number) || 0,
|
size: (p.size as number) || 0,
|
||||||
mimeType: (p.mimeType as string) || '',
|
mimeType: (p.mimeType as string) || '',
|
||||||
serverPath: (p.serverPath as string) || '',
|
serverPath: sp,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, ariaMsg]));
|
setMessages(prev => {
|
||||||
|
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
|
||||||
|
// folge kann variieren, und die chat-Payload traegt die files jetzt
|
||||||
|
// selbst), keine doppelte Solo-Bubble anlegen.
|
||||||
|
if (sp && prev.some(m =>
|
||||||
|
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
|
||||||
|
)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return capMessages([...prev, ariaMsg]);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,26 +1219,53 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const text = (message.payload.text as string) || '';
|
const text = (message.payload.text as string) || '';
|
||||||
const ts = message.timestamp;
|
const ts = message.timestamp;
|
||||||
|
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
|
||||||
|
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
|
||||||
|
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
|
||||||
|
// erschien, sondern erst nach einem Seitenwechsel/Reload).
|
||||||
|
const incomingFiles = Array.isArray((message.payload as any).files)
|
||||||
|
? (message.payload as any).files as Array<any> : [];
|
||||||
|
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
|
||||||
|
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
|
||||||
|
name: f.name || 'datei',
|
||||||
|
size: f.size || 0,
|
||||||
|
mimeType: f.mimeType || '',
|
||||||
|
serverPath: f.serverPath || '',
|
||||||
|
}));
|
||||||
|
const incomingPaths = new Set(
|
||||||
|
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
|
||||||
|
);
|
||||||
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
|
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const isDuplicate = prev.some(m =>
|
const isDuplicate = prev.some(m =>
|
||||||
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
|
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
|
||||||
);
|
);
|
||||||
if (isDuplicate) return prev;
|
if (isDuplicate) return prev;
|
||||||
|
const payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
|
||||||
|
const mergedAtts = [...payloadAtts, ...fileAttachments];
|
||||||
const ariaMsg: ChatMessage = {
|
const ariaMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
text,
|
text,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
attachments: mergedAtts.length ? mergedAtts : undefined,
|
||||||
messageId: (message.payload.messageId as string) || undefined,
|
messageId: (message.payload.messageId as string) || undefined,
|
||||||
backupTs: (message.payload.backupTs as number) || undefined,
|
backupTs: (message.payload.backupTs as number) || undefined,
|
||||||
projectId: ((message.payload as any).projectId as string) || '',
|
projectId: ((message.payload as any).projectId as string) || '',
|
||||||
answeredBy: ((message.payload as any).answeredBy as string) || '',
|
answeredBy: ((message.payload as any).answeredBy as string) || '',
|
||||||
};
|
};
|
||||||
|
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
|
||||||
|
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
|
||||||
|
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
|
||||||
|
const deduped = incomingPaths.size
|
||||||
|
? prev.filter(m => !(
|
||||||
|
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
|
||||||
|
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
|
||||||
|
))
|
||||||
|
: prev;
|
||||||
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
||||||
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
||||||
return capMessages([...prev, ariaMsg]).map(m =>
|
return capMessages([...deduped, ariaMsg]).map(m =>
|
||||||
m.sender === 'user'
|
m.sender === 'user'
|
||||||
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
|
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
|
||||||
? { ...m, deliveryStatus: 'delivered' }
|
? { ...m, deliveryStatus: 'delivered' }
|
||||||
@@ -1194,6 +1288,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
: prev);
|
: prev);
|
||||||
setAgentActivity(cur =>
|
setAgentActivity(cur =>
|
||||||
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
|
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
|
||||||
|
// ── Pro-Projekt-Queue-Automat: auf ARIAs Antwort reagieren ──
|
||||||
|
const awaiting = !!(message.payload as any).awaiting_reply;
|
||||||
|
const qapi = queueApiRef.current;
|
||||||
|
if (qapi) {
|
||||||
|
const st = qapi.getCtxState(ansPid);
|
||||||
|
// Nur reagieren, wenn wir fuer diesen Kontext wirklich auf eine
|
||||||
|
// Antwort warten (nicht bei unaufgeforderten Trigger-Nachrichten).
|
||||||
|
if (st === 'running' || st === 'awaiting_reply') {
|
||||||
|
if (awaiting) qapi.setCtxState(ansPid, 'awaiting_reply');
|
||||||
|
else qapi.advanceQueue(ansPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
|
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
|
||||||
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
|
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
|
||||||
@@ -1213,6 +1319,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
|
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
|
||||||
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
|
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
|
||||||
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||||
|
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
||||||
|
// stoppen — onPlaybackFinished liest converseRef. Default true.
|
||||||
|
converseRef.current = (message.payload as any).converse !== false;
|
||||||
const _isSilent = (message.payload as any).speak === false;
|
const _isSilent = (message.payload as any).speak === false;
|
||||||
if (_isSilent && wakeWordService.isConversing()) {
|
if (_isSilent && wakeWordService.isConversing()) {
|
||||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
||||||
@@ -1451,8 +1560,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
|
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
|
||||||
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
|
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
|
||||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
||||||
|
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
||||||
|
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
|
||||||
const bg = AppState.currentState !== 'active';
|
const bg = AppState.currentState !== 'active';
|
||||||
wakeWordService.endConversation(bg).catch(() => {});
|
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
|
||||||
});
|
});
|
||||||
return () => unsubPlayback();
|
return () => unsubPlayback();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1528,9 +1639,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Kein Speech im Window → Konversation beenden
|
// Kein Speech im Window → Konversation beenden
|
||||||
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
|
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
|
||||||
// Placeholder-Bubble wieder weg
|
// NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
|
||||||
|
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
|
||||||
|
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
|
||||||
|
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||||
|
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||||
if (ev.audioRequestId) {
|
if (ev.audioRequestId) {
|
||||||
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
|
setMessages(prev => prev.filter(m =>
|
||||||
|
!(m.audioRequestId === ev.audioRequestId
|
||||||
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
||||||
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
||||||
@@ -1614,6 +1731,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
// das Mikro greifen kann.
|
// das Mikro greifen kann.
|
||||||
wakeWordService.stopBargeListening().catch(() => {});
|
wakeWordService.stopBargeListening().catch(() => {});
|
||||||
});
|
});
|
||||||
|
// Aus der TTS-Queue nachgespielte (zweite) Antwort: ihren WAV-Cache-Pfad an
|
||||||
|
// die Bubble haengen, damit der Mund-Button/Play sie auch abspielen kann.
|
||||||
|
const unsubPcmCached = audioService.onPcmCached((messageId, audioPath) => {
|
||||||
|
if (!messageId || !audioPath) return;
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.messageId === messageId ? { ...m, audioPath } : m));
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubWake();
|
unsubWake();
|
||||||
@@ -1622,6 +1746,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
unsubPassive();
|
unsubPassive();
|
||||||
unsubTtsStart();
|
unsubTtsStart();
|
||||||
unsubTtsEnd();
|
unsubTtsEnd();
|
||||||
|
unsubPcmCached();
|
||||||
};
|
};
|
||||||
}, [wakeWordActive]);
|
}, [wakeWordActive]);
|
||||||
|
|
||||||
@@ -1919,6 +2044,65 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// --- Nachricht senden ---
|
// --- Nachricht senden ---
|
||||||
|
|
||||||
|
// ── Pro-Projekt-Queue: Zustands-Helfer ──────────────────────────
|
||||||
|
const getCtxState = useCallback((pid: string): CtxState =>
|
||||||
|
projectStatesRef.current[pid] || 'idle', []);
|
||||||
|
|
||||||
|
const setCtxState = useCallback((pid: string, s: CtxState) => {
|
||||||
|
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
|
||||||
|
setProjectStates(prev => ({ ...prev, [pid]: s }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCtxQueue = useCallback((pid: string, items: QueuedItem[]) => {
|
||||||
|
projectQueuesRef.current = { ...projectQueuesRef.current, [pid]: items };
|
||||||
|
setProjectQueues(prev => ({ ...prev, [pid]: items }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sendet eine Nachricht WIRKLICH (Location holen + dispatchWithAck) und setzt
|
||||||
|
// den Kontext auf 'running'. existingId gesetzt = eine bereits als pending_queue
|
||||||
|
// angezeigte Bubble wird auf 'sending' gehoben (Dequeue), sonst neue Bubble.
|
||||||
|
const actuallySend = useCallback(async (pid: string, text: string, existingId?: string) => {
|
||||||
|
const cmid = nextClientMsgId();
|
||||||
|
const location = await getCurrentLocation();
|
||||||
|
const status: ChatMessage['deliveryStatus'] =
|
||||||
|
connectionStateRef.current === 'connected' ? 'sending' : 'queued';
|
||||||
|
if (existingId) {
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === existingId ? { ...m, clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1 } : m));
|
||||||
|
} else {
|
||||||
|
setMessages(prev => capMessages([...prev, {
|
||||||
|
id: nextId(), sender: 'user', text, timestamp: Date.now(),
|
||||||
|
clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1, projectId: pid,
|
||||||
|
}]));
|
||||||
|
}
|
||||||
|
setCtxState(pid, 'running');
|
||||||
|
dispatchWithAck(cmid, 'chat', {
|
||||||
|
text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current,
|
||||||
|
projectId: pid, ...(location && { location }),
|
||||||
|
});
|
||||||
|
}, [getCurrentLocation, dispatchWithAck, setCtxState]);
|
||||||
|
|
||||||
|
// Naechsten Queue-Eintrag von pid abarbeiten (falls vorhanden), sonst idle.
|
||||||
|
const advanceQueue = useCallback((pid: string) => {
|
||||||
|
const q = projectQueuesRef.current[pid] || [];
|
||||||
|
if (q.length === 0) { setCtxState(pid, 'idle'); return; }
|
||||||
|
const [next, ...rest] = q;
|
||||||
|
setCtxQueue(pid, rest);
|
||||||
|
actuallySend(pid, next.text, next.id);
|
||||||
|
}, [actuallySend, setCtxQueue, setCtxState]);
|
||||||
|
|
||||||
|
// Einen wartenden Eintrag aus der Queue entfernen (User tippt auf ✕).
|
||||||
|
const removeQueued = useCallback((pid: string, id: string) => {
|
||||||
|
setCtxQueue(pid, (projectQueuesRef.current[pid] || []).filter(it => it.id !== id));
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== id));
|
||||||
|
}, [setCtxQueue]);
|
||||||
|
|
||||||
|
// Ref-Bruecke fuellen, damit der frueh deklarierte Message-Handler den Automaten
|
||||||
|
// erreicht (ohne TDZ / stale Closure).
|
||||||
|
useEffect(() => {
|
||||||
|
queueApiRef.current = { getCtxState, setCtxState, advanceQueue };
|
||||||
|
}, [getCtxState, setCtxState, advanceQueue]);
|
||||||
|
|
||||||
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
|
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
|
||||||
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
|
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
|
||||||
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
|
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
|
||||||
@@ -1942,49 +2126,6 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendTextMessage = useCallback(async () => {
|
|
||||||
const text = inputText.trim();
|
|
||||||
|
|
||||||
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
|
|
||||||
if (pendingAttachments.length > 0) {
|
|
||||||
sendPendingAttachments(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
setInputText('');
|
|
||||||
|
|
||||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
|
||||||
const location = await getCurrentLocation();
|
|
||||||
|
|
||||||
const cmid = nextClientMsgId();
|
|
||||||
const activePid = focusedProjectIdRef.current;
|
|
||||||
const userMsg: ChatMessage = {
|
|
||||||
id: nextId(),
|
|
||||||
sender: 'user',
|
|
||||||
text,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
clientMsgId: cmid,
|
|
||||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
||||||
sendAttempts: 1,
|
|
||||||
projectId: activePid,
|
|
||||||
};
|
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
|
||||||
|
|
||||||
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
|
|
||||||
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
|
|
||||||
dispatchWithAck(cmid, 'chat', {
|
|
||||||
text,
|
|
||||||
voice: localXttsVoiceRef.current,
|
|
||||||
speed: ttsSpeedRef.current,
|
|
||||||
interrupted: wasInterrupted,
|
|
||||||
projectId: activePid,
|
|
||||||
...(location && { location }),
|
|
||||||
});
|
|
||||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
|
||||||
|
|
||||||
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
|
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
|
||||||
const cancelRequest = useCallback(() => {
|
const cancelRequest = useCallback(() => {
|
||||||
const pid = focusedProjectIdRef.current || '';
|
const pid = focusedProjectIdRef.current || '';
|
||||||
@@ -1992,34 +2133,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
|
setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
|
||||||
clearStuckWatchdog();
|
clearStuckWatchdog();
|
||||||
rvs.send('cancel_request' as any, { projectId: pid });
|
rvs.send('cancel_request' as any, { projectId: pid });
|
||||||
}, []);
|
// Aktuellen Task abgebrochen → naechsten Queue-Eintrag dieses Projekts
|
||||||
|
// starten (oder idle, wenn leer). Wartende Eintraege einzeln loeschbar.
|
||||||
|
advanceQueue(pid);
|
||||||
|
}, [advanceQueue]);
|
||||||
|
|
||||||
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
|
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
|
||||||
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
|
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
|
||||||
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
|
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
|
||||||
// mach lieber X" sagen wie in einem echten Gespraech.
|
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
|
||||||
|
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
|
||||||
|
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
|
||||||
const interruptAriaIfBusy = useCallback(() => {
|
const interruptAriaIfBusy = useCallback(() => {
|
||||||
const speaking = audioService.isPlayingAudio();
|
if (audioService.isPlayingAudio()) {
|
||||||
// Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht
|
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
|
||||||
// global. Sonst bricht eine Nachricht im Hauptchat die parallele Arbeit in
|
|
||||||
// einem Projekt ab (bzw. wird faelschlich als Barge-In behandelt und die
|
|
||||||
// eigene Anfrage geht unter). Der Busy-Status kommt kontextgenau aus
|
|
||||||
// /projects/queue-status (queueStatusRef). agentActivity ist global und
|
|
||||||
// taugt dafuer nicht.
|
|
||||||
const pid = focusedProjectIdRef.current || '';
|
|
||||||
const focusKey = pid || '__main__';
|
|
||||||
const focusBusy = !!queueStatusRef.current?.[focusKey]?.busy;
|
|
||||||
if (!speaking && !focusBusy) return false;
|
|
||||||
console.log('[Chat] Barge-In: speaking=%s focusBusy=%s (ctx=%s) — interrupting',
|
|
||||||
speaking, focusBusy, focusKey);
|
|
||||||
// TTS immer stoppen wenn ARIA gerade spricht — egal welcher Kontext.
|
|
||||||
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
|
||||||
// Brain-Arbeit nur abbrechen wenn GENAU dieser Kontext arbeitet.
|
|
||||||
if (focusBusy) {
|
|
||||||
clearStuckWatchdog();
|
|
||||||
rvs.send('cancel_request' as any, { projectId: pid });
|
|
||||||
}
|
}
|
||||||
return true;
|
return false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
|
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
|
||||||
@@ -2070,6 +2199,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
||||||
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
||||||
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
||||||
|
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
|
||||||
|
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
|
||||||
|
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
|
||||||
|
// kein leeres Endpoint, das startPassiveStreamingRecording erneut ausloest
|
||||||
|
// (genau das liess die 30s vorher von vorne loslaufen).
|
||||||
|
if (wakeWordService.getState() === 'listening') {
|
||||||
|
console.log('[Chat] Manueller Stop im Passiv-Lauschen → exitPassiveListening (Ende)');
|
||||||
|
await wakeWordService.exitPassiveListening('manual');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await audioService.stopStreamingRecording('user');
|
await audioService.stopStreamingRecording('user');
|
||||||
if (wakeWordService.isConversing()) {
|
if (wakeWordService.isConversing()) {
|
||||||
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
||||||
@@ -2181,6 +2320,53 @@ const ChatScreen: React.FC = () => {
|
|||||||
setInputText('');
|
setInputText('');
|
||||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||||
|
|
||||||
|
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||||
|
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||||
|
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||||
|
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
||||||
|
const sendTextMessage = useCallback(async () => {
|
||||||
|
const text = inputText.trim();
|
||||||
|
|
||||||
|
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
|
||||||
|
if (pendingAttachments.length > 0) {
|
||||||
|
sendPendingAttachments(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const activePid = focusedProjectIdRef.current;
|
||||||
|
setInputText('');
|
||||||
|
// Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt).
|
||||||
|
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
|
||||||
|
|
||||||
|
const focusKey = activePid || '__main__';
|
||||||
|
const brainBusy = !!queueStatusRef.current?.[focusKey]?.busy;
|
||||||
|
let state = getCtxState(activePid);
|
||||||
|
// Fallback: ein per SPRACHE gestarteter Turn streamt live und setzt den
|
||||||
|
// App-State nicht — aber der Brain meldet busy. Dann Kontext als 'running'
|
||||||
|
// behandeln (anstellen; die spaetere Antwort schaltet die Queue weiter).
|
||||||
|
if (state === 'idle' && brainBusy) { setCtxState(activePid, 'running'); state = 'running'; }
|
||||||
|
if (state === 'running') {
|
||||||
|
// ARIA arbeitet noch am aktuellen Task → ANSTELLEN statt abbrechen.
|
||||||
|
// Sichtbare pending_queue-Bubble; laeuft der Reihe nach, wenn der
|
||||||
|
// aktuelle Task (und ggf. seine Rueckfragen) fertig ist.
|
||||||
|
const id = nextId();
|
||||||
|
setMessages(prev => capMessages([...prev, {
|
||||||
|
id, sender: 'user', text, timestamp: Date.now(),
|
||||||
|
projectId: activePid, deliveryStatus: 'pending_queue',
|
||||||
|
}]));
|
||||||
|
setCtxQueue(activePid, [...(projectQueuesRef.current[activePid] || []), { id, text }]);
|
||||||
|
console.log('[Chat] angestellt (queue) project=%s len=%d', activePid || '(main)',
|
||||||
|
(projectQueuesRef.current[activePid] || []).length);
|
||||||
|
} else {
|
||||||
|
// idle ODER awaiting_reply → sofort senden. Bei awaiting_reply ist DAS die
|
||||||
|
// Antwort auf ARIAs Rueckfrage (normaler Turn in diesem Projekt).
|
||||||
|
console.log('[Chat] sende sofort (state=%s) project=%s', state, activePid || '(main)');
|
||||||
|
actuallySend(activePid, text);
|
||||||
|
}
|
||||||
|
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
|
|
||||||
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
||||||
@@ -2479,7 +2665,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
{isUser && item.deliveryStatus ? (
|
{isUser && item.deliveryStatus ? (
|
||||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
item.deliveryStatus === 'pending_queue' ? (
|
||||||
|
// Wartet in der Projekt-Queue → tippen entfernt den Eintrag.
|
||||||
|
<TouchableOpacity
|
||||||
|
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||||
|
onPress={() => removeQueued(item.projectId || '', item.id)}
|
||||||
|
accessibilityLabel="Aus Warteschlange entfernen"
|
||||||
|
>
|
||||||
|
<Text style={styles.statusQueued}>{'⏸ wartet · ✕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||||
onPress={() => retryFailedMessage(item.clientMsgId!)}
|
onPress={() => retryFailedMessage(item.clientMsgId!)}
|
||||||
@@ -2902,6 +3097,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rueckfrage-Banner / Queue-Zaehler fuer den fokussierten Kontext */}
|
||||||
|
{(projectStates[focusedProjectId] === 'awaiting_reply' ||
|
||||||
|
(projectQueues[focusedProjectId]?.length || 0) > 0) && (
|
||||||
|
<View style={styles.queueBanner}>
|
||||||
|
<Text style={styles.queueBannerText}>
|
||||||
|
{projectStates[focusedProjectId] === 'awaiting_reply'
|
||||||
|
? '❓ ARIA fragt nach — deine Eingabe beantwortet das'
|
||||||
|
: `⏸ ${projectQueues[focusedProjectId]?.length || 0} in der Warteschlange`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eingabebereich */}
|
{/* Eingabebereich */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
{/* Datei-Buttons */}
|
{/* Datei-Buttons */}
|
||||||
@@ -3399,6 +3606,19 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
queueBanner: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#2A2410',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#4A3F14',
|
||||||
|
},
|
||||||
|
queueBannerText: {
|
||||||
|
color: '#FFD60A',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
|
|||||||
@@ -284,6 +284,13 @@ class AudioService {
|
|||||||
private pcmSampleRate: number = 24000;
|
private pcmSampleRate: number = 24000;
|
||||||
private pcmChannels: number = 1;
|
private pcmChannels: number = 1;
|
||||||
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build
|
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build
|
||||||
|
// ── TTS-Abspiel-Queue: zwei back-to-back-Antworten sollen sich NICHT
|
||||||
|
// gegenseitig abschneiden. Eine neue hoerbare Antwort, die reinkommt waehrend
|
||||||
|
// eine andere noch HOERBAR spielt, wird gepuffert und nach PcmPlaybackFinished
|
||||||
|
// nachgespielt (statt via start()→stopInternal() die laufende zu cutten). ──
|
||||||
|
private pcmAudiblePlaying: boolean = false; // eine hoerbare Antwort spielt (bis PcmPlaybackFinished)
|
||||||
|
private pcmPlayingMsgId: string = ''; // deren messageId
|
||||||
|
private pcmPendingStreams: Array<{ messageId: string; sampleRate: number; channels: number; chunks: string[]; final: boolean }> = [];
|
||||||
private pcmBytesCollected: number = 0;
|
private pcmBytesCollected: number = 0;
|
||||||
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
|
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
|
||||||
|
|
||||||
@@ -374,6 +381,16 @@ class AudioService {
|
|||||||
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
|
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
|
||||||
emitter.addListener('PcmPlaybackFinished', () => {
|
emitter.addListener('PcmPlaybackFinished', () => {
|
||||||
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
|
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
|
||||||
|
this.pcmAudiblePlaying = false;
|
||||||
|
this.pcmPlayingMsgId = '';
|
||||||
|
// TTS-Abspiel-Queue: steht eine naechste Antwort bereit? Dann NICHT
|
||||||
|
// "fertig" melden (kein Wake-Word-Re-Arm / Conversation-Ende) — ARIA
|
||||||
|
// spricht gleich weiter. Die naechste gepufferte Antwort direkt spielen.
|
||||||
|
if (this.pcmPendingStreams.length > 0) {
|
||||||
|
this._promoteNextPendingStream().catch(err =>
|
||||||
|
console.warn('[Audio] promote next pending stream err:', err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._releaseFocusDeferred();
|
this._releaseFocusDeferred();
|
||||||
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
|
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
|
||||||
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
|
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
|
||||||
@@ -1401,6 +1418,23 @@ class AudioService {
|
|||||||
const base64 = payload.base64 || '';
|
const base64 = payload.base64 || '';
|
||||||
const isFinal = !!payload.final;
|
const isFinal = !!payload.final;
|
||||||
|
|
||||||
|
// ── TTS-Abspiel-Queue ──
|
||||||
|
// Kommt eine NEUE hoerbare Antwort rein, waehrend eine andere noch hoerbar
|
||||||
|
// spielt? Dann NICHT starten (start()→stopInternal() wuerde die laufende
|
||||||
|
// abschneiden) — puffern und nach deren PcmPlaybackFinished nachspielen.
|
||||||
|
if (!silent && this.pcmAudiblePlaying && messageId && messageId !== this.pcmPlayingMsgId) {
|
||||||
|
let entry = this.pcmPendingStreams.find(e => e.messageId === messageId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { messageId, sampleRate, channels, chunks: [], final: false };
|
||||||
|
this.pcmPendingStreams.push(entry);
|
||||||
|
console.log('[Audio] TTS-Queue: Antwort %s wird gepuffert (spielt gerade %s)',
|
||||||
|
messageId, this.pcmPlayingMsgId);
|
||||||
|
}
|
||||||
|
if (base64) entry.chunks.push(base64);
|
||||||
|
if (isFinal) entry.final = true;
|
||||||
|
return ''; // Live-Player nicht anfassen
|
||||||
|
}
|
||||||
|
|
||||||
// Neuer Stream? (messageId Wechsel oder nicht aktiv)
|
// Neuer Stream? (messageId Wechsel oder nicht aktiv)
|
||||||
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
|
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
|
||||||
if (this.pcmStreamActive && !silent) {
|
if (this.pcmStreamActive && !silent) {
|
||||||
@@ -1448,6 +1482,8 @@ class AudioService {
|
|||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
AudioFocus?.requestDuck().catch(() => {});
|
AudioFocus?.requestDuck().catch(() => {});
|
||||||
this._firePlaybackStarted();
|
this._firePlaybackStarted();
|
||||||
|
this.pcmAudiblePlaying = true;
|
||||||
|
this.pcmPlayingMsgId = messageId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,6 +1526,73 @@ class AudioService {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Naechste gepufferte TTS-Antwort abspielen (TTS-Abspiel-Queue). Wird nach
|
||||||
|
* PcmPlaybackFinished der vorherigen aufgerufen — so sprechen zwei
|
||||||
|
* back-to-back-Antworten NACHEINANDER statt sich abzuschneiden. */
|
||||||
|
private async _promoteNextPendingStream(): Promise<void> {
|
||||||
|
const entry = this.pcmPendingStreams.shift();
|
||||||
|
if (!entry) return;
|
||||||
|
// Inzwischen global gemutet / im Anruf / vom User gestoppt? Dann NICHT
|
||||||
|
// hoerbar abspielen — nur cachen und die naechste promoten.
|
||||||
|
const mutedNow = this._muted || this._pausedForCall ||
|
||||||
|
(!!this._stoppedMessageId && this._stoppedMessageId === entry.messageId);
|
||||||
|
console.log('[Audio] TTS-Queue: spiele gepufferte Antwort %s (%d chunks, final=%s, muted=%s)',
|
||||||
|
entry.messageId, entry.chunks.length, entry.final, mutedNow);
|
||||||
|
// SOFORT als "spielt" markieren (vor jedem await) — sonst koennte ein
|
||||||
|
// gleichzeitig eintreffender Chunk einer DRITTEN Antwort in der await-Luecke
|
||||||
|
// einen konkurrierenden Stream starten statt zu puffern.
|
||||||
|
this.pcmPlayingMsgId = entry.messageId;
|
||||||
|
this.pcmAudiblePlaying = !mutedNow;
|
||||||
|
// Cache-State fuer den WAV-Build (Mund-Button-Replay) setzen.
|
||||||
|
this.pcmMessageId = entry.messageId;
|
||||||
|
this.pcmSampleRate = entry.sampleRate;
|
||||||
|
this.pcmChannels = entry.channels;
|
||||||
|
this.pcmBuffer = entry.chunks.slice();
|
||||||
|
this.pcmBytesCollected = entry.chunks.reduce((n, c) => n + Math.floor(c.length * 0.75), 0);
|
||||||
|
this.pcmStreamActive = true;
|
||||||
|
if (!mutedNow && PcmStreamPlayer) {
|
||||||
|
try {
|
||||||
|
const prerollSec = await loadPrerollSec();
|
||||||
|
await PcmStreamPlayer.start(entry.sampleRate, entry.channels, prerollSec);
|
||||||
|
this._cancelDeferredFocusRelease();
|
||||||
|
AudioFocus?.requestDuck().catch(() => {});
|
||||||
|
this._firePlaybackStarted();
|
||||||
|
this.pcmAudiblePlaying = true;
|
||||||
|
this.pcmPlayingMsgId = entry.messageId;
|
||||||
|
for (const c of entry.chunks) {
|
||||||
|
try { await PcmStreamPlayer.writeChunk(c); } catch (err) { console.warn('[Audio] promote writeChunk', err); }
|
||||||
|
}
|
||||||
|
if (entry.final) { try { await PcmStreamPlayer.end(); } catch {} }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Audio] TTS-Queue promote start fehlgeschlagen:', err);
|
||||||
|
this.pcmAudiblePlaying = false;
|
||||||
|
this.pcmPlayingMsgId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// War die Antwort schon komplett (final) da: WAV cachen + State wie im
|
||||||
|
// Normalpfad zuruecksetzen. Bei NICHT-final laeuft der Rest live ueber
|
||||||
|
// _handlePcmChunkImpl (messageId == pcmPlayingMsgId → Normalpfad).
|
||||||
|
if (entry.final) {
|
||||||
|
this.pcmStreamActive = false;
|
||||||
|
if (this.pcmBuffer.length > 0) {
|
||||||
|
const audioPath = await this._savePcmBufferAsWav(entry.messageId).catch(() => '');
|
||||||
|
if (audioPath) {
|
||||||
|
this.pcmCachedListeners.forEach(cb => {
|
||||||
|
try { cb(entry.messageId, audioPath); } catch (e) { console.warn('[Audio] pcmCached cb err:', e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pcmBuffer = [];
|
||||||
|
this.pcmBytesCollected = 0;
|
||||||
|
this.pcmMessageId = '';
|
||||||
|
// Nicht hoerbar abgespielt (gemutet)? Dann feuert PcmPlaybackFinished nicht
|
||||||
|
// → die naechste gepufferte Antwort selbst nachziehen (Kette).
|
||||||
|
if (!this.pcmAudiblePlaying) {
|
||||||
|
await this._promoteNextPendingStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
|
/** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
|
||||||
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
|
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
@@ -1578,6 +1681,19 @@ class AudioService {
|
|||||||
// Callback wenn alle Audio-Teile abgespielt sind
|
// Callback wenn alle Audio-Teile abgespielt sind
|
||||||
private playbackFinishedListeners: (() => void)[] = [];
|
private playbackFinishedListeners: (() => void)[] = [];
|
||||||
private playbackStartedListeners: (() => void)[] = [];
|
private playbackStartedListeners: (() => void)[] = [];
|
||||||
|
// Feuert wenn eine aus der TTS-Queue NACHgespielte Antwort ihren WAV-Cache
|
||||||
|
// geschrieben hat — der Normalpfad meldet den Pfad ueber den handlePcmChunk-
|
||||||
|
// Rueckgabewert, gepufferte (zweite) Antworten koennen das aber nicht (ihre
|
||||||
|
// Chunks returnen '' waehrend sie warten). Damit setzt die App auch fuer die
|
||||||
|
// nachgespielte Antwort m.audioPath (Mund-Button-Replay).
|
||||||
|
private pcmCachedListeners: Array<(messageId: string, audioPath: string) => void> = [];
|
||||||
|
|
||||||
|
onPcmCached(callback: (messageId: string, audioPath: string) => void): () => void {
|
||||||
|
this.pcmCachedListeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.pcmCachedListeners = this.pcmCachedListeners.filter(cb => cb !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
onPlaybackFinished(callback: () => void): () => void {
|
onPlaybackFinished(callback: () => void): () => void {
|
||||||
this.playbackFinishedListeners.push(callback);
|
this.playbackFinishedListeners.push(callback);
|
||||||
@@ -1759,6 +1875,10 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
|
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
|
||||||
PcmStreamPlayer?.stop().catch(() => {});
|
PcmStreamPlayer?.stop().catch(() => {});
|
||||||
|
// Wartende TTS-Antworten verwerfen (Mund-Button = still sein).
|
||||||
|
this.pcmPendingStreams = [];
|
||||||
|
this.pcmAudiblePlaying = false;
|
||||||
|
this.pcmPlayingMsgId = '';
|
||||||
stopBackgroundAudio().catch(() => {});
|
stopBackgroundAudio().catch(() => {});
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
@@ -1770,7 +1890,8 @@ class AudioService {
|
|||||||
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
|
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
|
||||||
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
|
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
|
||||||
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound
|
||||||
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying);
|
|| this.pcmStreamActive || this.audioQueue.length || this.isPlaying
|
||||||
|
|| this.pcmPendingStreams.length);
|
||||||
if (!hasAnything) return;
|
if (!hasAnything) return;
|
||||||
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
|
||||||
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
|
||||||
@@ -1804,6 +1925,11 @@ class AudioService {
|
|||||||
this.pcmBuffer = [];
|
this.pcmBuffer = [];
|
||||||
this.pcmBytesCollected = 0;
|
this.pcmBytesCollected = 0;
|
||||||
this.pcmMessageId = '';
|
this.pcmMessageId = '';
|
||||||
|
// TTS-Abspiel-Queue verwerfen — harter Stop/Abbruch/Barge-In soll auch
|
||||||
|
// wartende Antworten fallenlassen (sonst sprechen sie nach dem Stop weiter).
|
||||||
|
this.pcmPendingStreams = [];
|
||||||
|
this.pcmAudiblePlaying = false;
|
||||||
|
this.pcmPlayingMsgId = '';
|
||||||
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
|
// Audio-Focus sofort freigeben — User hat explizit abgebrochen.
|
||||||
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
|
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
|
||||||
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
|
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
|
||||||
|
|||||||
+280
-30
@@ -237,19 +237,38 @@ META_TOOLS = [
|
|||||||
"speak": {
|
"speak": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": (
|
"description": (
|
||||||
"Soll die Antwort dieses Skills VORGELESEN werden (TTS)? "
|
"STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
|
||||||
"Default false. \n"
|
"Default false.\n\n"
|
||||||
"- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, "
|
"WARUM es das gibt: ARIA ist voice-first. Ein reiner "
|
||||||
"Rollade): es gibt nichts vorzulesen, die App beendet direkt "
|
"STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
|
||||||
"und lauscht wieder aufs Wake-Word (knackig, wie ein "
|
"vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
|
||||||
"Kommando).\n"
|
"Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
|
||||||
"- true = ANTWORT-Skill: das Ergebnis ist eine Information, "
|
"eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
|
||||||
"die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein "
|
"'was laeuft gerade'). Also: Kommando-Skill=false, "
|
||||||
"Nachschlage-Wert, ein Status). Dann wird die Antwort "
|
"Antwort-Skill=true.\n\n"
|
||||||
"vorgelesen und das Gespraechs-Fenster bleibt offen.\n"
|
"PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
|
||||||
"Gilt fuer Fast-Path UND wenn das lokale LLM den Skill "
|
"beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
|
||||||
"aufruft. Im Zweifel bei Kommandos false, bei "
|
"vorlesen), kann dein run.py im JSON-Output pro Aufruf "
|
||||||
"Frage-Antwort-Skills true."
|
"`speak` (und `converse`, s.u.) setzen — das ueberschreibt "
|
||||||
|
"diesen statischen Default. Beispiel-Output:\n"
|
||||||
|
" next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n"
|
||||||
|
" was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false,"
|
||||||
|
"\"reply\":\"Laeuft: …\"}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"converse": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN "
|
||||||
|
"(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n"
|
||||||
|
"WARUM getrennt von speak: 'vorlesen' und 'weiterreden "
|
||||||
|
"koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' "
|
||||||
|
"soll vorgelesen werden (speak=true), aber es ist eine "
|
||||||
|
"abgeschlossene Einzel-Info — Stefan will danach NICHT ins "
|
||||||
|
"30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur "
|
||||||
|
"wenn eine echte Rueckfrage/ein Dialog sinnvoll ist "
|
||||||
|
"(converse=true). Am besten pro Aufruf im run.py-Output "
|
||||||
|
"setzen (dynamisch), nicht statisch."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -265,6 +284,27 @@ META_TOOLS = [
|
|||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_get",
|
||||||
|
"description": (
|
||||||
|
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
|
||||||
|
"args, fast_patterns, speak/converse) + kompletter entry_code "
|
||||||
|
"(der echte Python-Code) + README. IMMER vor `skill_update` "
|
||||||
|
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
|
||||||
|
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
|
||||||
|
"leicht funktionierende Teile!)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Skill-Name."},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -331,8 +371,18 @@ META_TOOLS = [
|
|||||||
"speak": {
|
"speak": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": (
|
"description": (
|
||||||
"Vorlesen ja/nein (siehe skill_create). false = "
|
"Statischer Vorlese-Default (siehe skill_create). false = "
|
||||||
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen."
|
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro "
|
||||||
|
"Aufruf via run.py-JSON-Output ueberschreibbar."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"converse": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"Statischer Default: nach der Antwort 30s weiterlauschen "
|
||||||
|
"(Dialog=true) oder direkt zurueck aufs Wake-Word (false, "
|
||||||
|
"Default). Siehe skill_create. Pro Aufruf via Output "
|
||||||
|
"ueberschreibbar."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1064,6 +1114,72 @@ def _fold_umlauts(s: str) -> str:
|
|||||||
.replace("ß", "ss"))
|
.replace("ß", "ss"))
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_skill_action(text: str) -> bool:
|
||||||
|
"""Erkennt eine BEHAUPTETE Steuerbefehl-Quittung (v.a. Spotify) im Reply.
|
||||||
|
Genutzt fuer den Anti-Halluzinations-Guard: sagt local sowas, hat aber KEIN
|
||||||
|
Werkzeug gerufen, ist real nichts passiert → eskalieren. Bewusst eng, um
|
||||||
|
normale Konversation ueber Musik nicht zu treffen."""
|
||||||
|
t = (text or "").strip().lower()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
if t.startswith("spotify:") or t.startswith("spotify -") or t.startswith("musik:"):
|
||||||
|
return True
|
||||||
|
if "playlist" in t and ("abspiel" in t or "spiele" in t or "starte" in t):
|
||||||
|
return True
|
||||||
|
if ("ueberspring" in t or "überspring" in t) and ("titel" in t or "lied" in t or "song" in t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _claims_live_media_state(text: str) -> bool:
|
||||||
|
"""Erkennt eine BEHAUPTETE Live-Zustands-Auskunft, die NUR ein Werkzeug
|
||||||
|
wissen kann: aktueller Song/Interpret, Restzeit, was gerade laeuft, welches
|
||||||
|
Geraet spielt. local erfindet das gern (mal 'Midnight City von M83', mal
|
||||||
|
'The House Of House') ohne run_spotify zu rufen. Ohne echten Skill-Aufruf ist
|
||||||
|
das reine Halluzination → eskalieren (Claude ruft das Tool zuverlaessig).
|
||||||
|
|
||||||
|
Bewusst auf Medien-/Wiedergabe-Zustand begrenzt, damit normale Musik-
|
||||||
|
Konversation ('ich mag M83') NICHT getroffen wird — es muss nach einer
|
||||||
|
konkreten Ist-Zustands-Auskunft klingen."""
|
||||||
|
t = (text or "").strip().lower()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
# Restzeit/Abspielposition ('noch 52 Sekunden', 'Restzeit 2:34').
|
||||||
|
if "restzeit" in t or "restlaufzeit" in t:
|
||||||
|
return True
|
||||||
|
if re.search(r"\bnoch\s+\d+\s*(sekunde|minute|sek|min)", t):
|
||||||
|
return True
|
||||||
|
# 'aktueller Song/Titel/Lied heisst/ist …' / 'es laeuft gerade …' /
|
||||||
|
# 'spielt gerade …' / 'jetzt laeuft …' — Ist-Zustands-Auskunft.
|
||||||
|
if re.search(r"(aktuell\w*\s+(song|titel|lied|stueck|track))", t):
|
||||||
|
return True
|
||||||
|
if re.search(r"(l(ae|ä)uft|spielt)\s+(gerade|jetzt|aktuell|zurzeit|grade)", t):
|
||||||
|
return True
|
||||||
|
if re.search(r"(gerade|jetzt|zurzeit|grade)\s+(l(ae|ä)uft|spielt)", t):
|
||||||
|
return True
|
||||||
|
# Skip-Quittung, die einen konkreten Folgetitel behauptet ('… ist jetzt „X"').
|
||||||
|
if ("ueberspring" in t or "übersprung" in t or "uebersprung" in t) and (
|
||||||
|
"song" in t or "titel" in t or "lied" in t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_AWAIT_MARKER_RE = re.compile(r"\[\[\s*AWAIT(?:_REPLY)?\s*\]\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_await_marker(text: str) -> tuple:
|
||||||
|
"""Erkennt ARIAs Rueckfrage-Marker `[[AWAIT]]` — den sie ans Ende haengt,
|
||||||
|
wenn ihre Antwort eine echte Rueckfrage ist, auf die sie eine Nutzer-Antwort
|
||||||
|
BRAUCHT, bevor der aktuelle Task fertig ist. Entfernt den Marker (nicht
|
||||||
|
anzeigen/vorlesen/in History) und meldet, ob er da war. Wie speak/converse:
|
||||||
|
ARIA deklariert den Zustand selbst — kein '?'-Raten."""
|
||||||
|
if not text:
|
||||||
|
return text, False
|
||||||
|
if _AWAIT_MARKER_RE.search(text):
|
||||||
|
return _AWAIT_MARKER_RE.sub("", text).strip(), True
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_fast_match(text: str) -> str:
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
norm = _strip_leading_hint_blocks(text).lower()
|
norm = _strip_leading_hint_blocks(text).lower()
|
||||||
norm = _fold_umlauts(norm)
|
norm = _fold_umlauts(norm)
|
||||||
@@ -1179,8 +1295,12 @@ class Agent:
|
|||||||
continue
|
continue
|
||||||
args = pat.get("args") or {}
|
args = pat.get("args") or {}
|
||||||
reply = pat.get("reply") or f"{skill_name}: ok"
|
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||||
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
|
# Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
|
||||||
|
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
|
||||||
|
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
|
||||||
|
# Skill darf ein Dialog-Skill sein.
|
||||||
self._fast_path_speak = bool(skill.get("speak", False))
|
self._fast_path_speak = bool(skill.get("speak", False))
|
||||||
|
self._fast_path_converse = bool(skill.get("converse", False))
|
||||||
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||||
skill_name, rx, user_message[:60])
|
skill_name, rx, user_message[:60])
|
||||||
try:
|
try:
|
||||||
@@ -1193,6 +1313,16 @@ class Agent:
|
|||||||
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
||||||
hint = (tail[-1] if tail else "")[:120]
|
hint = (tail[-1] if tail else "")[:120]
|
||||||
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
||||||
|
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
|
||||||
|
try:
|
||||||
|
_j = json.loads((res.get("stdout") or "").strip())
|
||||||
|
if isinstance(_j, dict):
|
||||||
|
if isinstance(_j.get("speak"), bool):
|
||||||
|
self._fast_path_speak = _j["speak"]
|
||||||
|
if isinstance(_j.get("converse"), bool):
|
||||||
|
self._fast_path_converse = _j["converse"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return reply
|
return reply
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1248,11 +1378,10 @@ class Agent:
|
|||||||
break
|
break
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
def _skill_speak_flag(self, tname: str) -> bool:
|
def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
|
||||||
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False =
|
"""run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
|
||||||
Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche)."""
|
|
||||||
if not tname.startswith("run_"):
|
if not tname.startswith("run_"):
|
||||||
return True
|
return None
|
||||||
suffix = tname[len("run_"):]
|
suffix = tname[len("run_"):]
|
||||||
m = skills_mod.read_manifest(suffix)
|
m = skills_mod.read_manifest(suffix)
|
||||||
if m is None:
|
if m is None:
|
||||||
@@ -1261,7 +1390,28 @@ class Agent:
|
|||||||
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
|
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
|
||||||
m = cand
|
m = cand
|
||||||
break
|
break
|
||||||
return bool((m or {}).get("speak", False))
|
return m
|
||||||
|
|
||||||
|
def _skill_speak_flag(self, tname: str) -> bool:
|
||||||
|
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False)."""
|
||||||
|
if not tname.startswith("run_"):
|
||||||
|
return True
|
||||||
|
return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False))
|
||||||
|
|
||||||
|
def _skill_response_flags(self, tname: str) -> tuple:
|
||||||
|
"""(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill.
|
||||||
|
Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) >
|
||||||
|
Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich
|
||||||
|
sein — 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop."""
|
||||||
|
flags = getattr(self, "_last_skill_flags", None) or {}
|
||||||
|
m = self._resolve_skill_manifest(tname) or {}
|
||||||
|
speak = bool(m.get("speak", False))
|
||||||
|
converse = bool(m.get("converse", False))
|
||||||
|
if isinstance(flags.get("speak"), bool):
|
||||||
|
speak = flags["speak"]
|
||||||
|
if isinstance(flags.get("converse"), bool):
|
||||||
|
converse = flags["converse"]
|
||||||
|
return speak, converse
|
||||||
|
|
||||||
def _try_local_fast_lane(self, user_message: str,
|
def _try_local_fast_lane(self, user_message: str,
|
||||||
active_project_id: str) -> Optional[str]:
|
active_project_id: str) -> Optional[str]:
|
||||||
@@ -1273,9 +1423,28 @@ class Agent:
|
|||||||
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
|
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
|
||||||
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
|
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
|
||||||
self._local_turn_speak = True
|
self._local_turn_speak = True
|
||||||
|
# Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via
|
||||||
|
# web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default
|
||||||
|
# False = Einzelaktion).
|
||||||
|
self._local_turn_converse = True
|
||||||
|
# Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
|
||||||
|
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
|
||||||
|
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
|
||||||
|
# Bestaetigung durchzulassen.
|
||||||
|
self._local_ran_skill = False
|
||||||
local_only = bool(cfg.get("localOnly"))
|
local_only = bool(cfg.get("localOnly"))
|
||||||
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
||||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
# B1a-Rueckbau (bewusst): local ist TOOL-LOS — nur reines Reden. Ein 8B
|
||||||
|
# ist ein unzuverlaessiger Tool-Caller: mit Werkzeug in der Hand erfindet
|
||||||
|
# er lieber eine plausible Antwort ('der Song ist X'), statt das Tool zu
|
||||||
|
# rufen — das war die Halo-Quelle UND zwang zu per-Skill-Guards. Ohne
|
||||||
|
# Tools KANN er kein Skill-Ergebnis faelschen. Alles mit Grundwahrheit
|
||||||
|
# (Fakt/Live-Zustand/Gedaechtnis/Aktion) gehoert an Claude oder an den
|
||||||
|
# deterministischen Fast-Path — nicht an den 8B. WIE es dorthin kommt:
|
||||||
|
# das Modell eskaliert SELBST (<<ESCALATE>>, siehe Prompt) — bewusst KEINE
|
||||||
|
# Input-Wortliste im Router. Der Output-Guard unten faengt ab, wenn der
|
||||||
|
# 8B doch mal statt zu eskalieren einen Live-Zustand behauptet.
|
||||||
|
tools = []
|
||||||
|
|
||||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
||||||
has_tools=bool(tools))
|
has_tools=bool(tools))
|
||||||
@@ -1324,7 +1493,9 @@ class Agent:
|
|||||||
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
|
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
|
||||||
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
|
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
|
||||||
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
|
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
|
||||||
self._local_turn_speak = self._skill_speak_flag(tname)
|
self._local_turn_speak, self._local_turn_converse = \
|
||||||
|
self._skill_response_flags(tname)
|
||||||
|
self._local_ran_skill = True
|
||||||
if self._local_tool_failed(tname, tresult):
|
if self._local_tool_failed(tname, tresult):
|
||||||
had_error = True
|
had_error = True
|
||||||
messages.append({"role": "tool",
|
messages.append({"role": "tool",
|
||||||
@@ -1370,6 +1541,15 @@ class Agent:
|
|||||||
logger.info("[router] lokal eskaliert → Claude")
|
logger.info("[router] lokal eskaliert → Claude")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Anti-Halluzination: local behauptet manchmal eine Musik-/Skill-Aktion
|
||||||
|
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
|
||||||
|
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
|
||||||
|
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
|
||||||
|
if not self._local_ran_skill and (
|
||||||
|
_looks_like_skill_action(final) or _claims_live_media_state(final)):
|
||||||
|
logger.info("[router] lokal: Aktion/Live-Zustand behauptet ohne Tool-Call → Claude")
|
||||||
|
return None
|
||||||
|
|
||||||
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
|
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
|
||||||
self.conversation.add("assistant", final, project_id=active_project_id)
|
self.conversation.add("assistant", final, project_id=active_project_id)
|
||||||
return final
|
return final
|
||||||
@@ -1422,7 +1602,10 @@ class Agent:
|
|||||||
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
||||||
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
||||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||||
return fast_reply, "fast-path", speak
|
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||||
|
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||||
|
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||||
|
return fast_reply, "fast-path", speak, converse, False
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 1. User-Turn an die Konversation
|
||||||
self.conversation.add("user", user_message, source=source,
|
self.conversation.add("user", user_message, source=source,
|
||||||
@@ -1436,10 +1619,12 @@ class Agent:
|
|||||||
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
||||||
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
||||||
if local_reply is not None:
|
if local_reply is not None:
|
||||||
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm +
|
# speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
|
||||||
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech.
|
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||||
speak = getattr(self, "_local_turn_speak", True)
|
speak = getattr(self, "_local_turn_speak", True)
|
||||||
return local_reply, "local", speak
|
converse = getattr(self, "_local_turn_converse", True)
|
||||||
|
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
||||||
|
return local_reply, "local", speak, converse, False
|
||||||
|
|
||||||
# 2. Hot Memory (alle pinned Punkte)
|
# 2. Hot Memory (alle pinned Punkte)
|
||||||
hot = self.store.list_pinned()
|
hot = self.store.list_pinned()
|
||||||
@@ -1486,6 +1671,23 @@ class Agent:
|
|||||||
oauth_callback_host=oauth_host,
|
oauth_callback_host=oauth_host,
|
||||||
oauth_callback_port=oauth_port,
|
oauth_callback_port=oauth_port,
|
||||||
oauth_callback_tls=oauth_tls)
|
oauth_callback_tls=oauth_tls)
|
||||||
|
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
|
||||||
|
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
|
||||||
|
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
|
||||||
|
# (statt als neuen Auftrag). Wie speak/converse: ARIA deklariert selbst.
|
||||||
|
system_prompt += (
|
||||||
|
"\n\n## RUECKFRAGE-SIGNAL [[AWAIT]]\n"
|
||||||
|
"Wenn deine Antwort eine echte RUECKFRAGE ist, auf die du Stefans "
|
||||||
|
"Antwort BRAUCHST, um den aktuellen Task abzuschliessen (z.B. 'Welche "
|
||||||
|
"der drei Playlists meinst du?', 'Soll ich X oder Y nehmen?'), haenge "
|
||||||
|
"als ALLERLETZTES exakt `[[AWAIT]]` an. Der Marker wird entfernt (nicht "
|
||||||
|
"angezeigt, nicht vorgelesen) und sagt der App: warte auf Stefans "
|
||||||
|
"Antwort, bevor der naechste Task der Warteschlange laeuft.\n"
|
||||||
|
"NUR bei echten, blockierenden Rueckfragen — NICHT bei rhetorischen "
|
||||||
|
"Fragen, unverbindlichen Vorschlaegen ('soll ich noch...?', die auch "
|
||||||
|
"ohne Antwort ok sind) oder wenn du den Task einfach fertig hast. Im "
|
||||||
|
"Zweifel: KEIN Marker."
|
||||||
|
)
|
||||||
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
|
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
|
||||||
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
|
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
|
||||||
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
|
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
|
||||||
@@ -1561,6 +1763,7 @@ class Agent:
|
|||||||
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
|
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
|
||||||
# Konversation bleiben gesprochen.
|
# Konversation bleiben gesprochen.
|
||||||
self._claude_turn_speak = True
|
self._claude_turn_speak = True
|
||||||
|
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
||||||
try:
|
try:
|
||||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||||
result = self.proxy.chat_full(messages, tools=tools,
|
result = self.proxy.chat_full(messages, tools=tools,
|
||||||
@@ -1582,7 +1785,8 @@ class Agent:
|
|||||||
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
|
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
|
||||||
_tn = tc.get("name") or ""
|
_tn = tc.get("name") or ""
|
||||||
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
|
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
|
||||||
self._claude_turn_speak = self._skill_speak_flag(_tn)
|
self._claude_turn_speak, self._claude_turn_converse = \
|
||||||
|
self._skill_response_flags(_tn)
|
||||||
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
|
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
|
||||||
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
|
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
|
||||||
# viel zu wenig: Spotify _all=true mit 90 Playlists
|
# viel zu wenig: Spotify _all=true mit 90 Playlists
|
||||||
@@ -1648,11 +1852,19 @@ class Agent:
|
|||||||
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
|
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
|
||||||
"sag mir einfach, was du brauchst.")
|
"sag mir einfach, was du brauchst.")
|
||||||
|
|
||||||
|
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
|
||||||
|
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
|
||||||
|
final_reply, awaiting_reply = _extract_await_marker(final_reply)
|
||||||
|
|
||||||
# 7. Assistant-Turn (final reply) in die Conversation
|
# 7. Assistant-Turn (final reply) in die Conversation
|
||||||
self.conversation.add("assistant", final_reply,
|
self.conversation.add("assistant", final_reply,
|
||||||
project_id=active_project_id)
|
project_id=active_project_id)
|
||||||
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True.
|
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
|
||||||
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True))
|
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||||
|
return (final_reply, "claude",
|
||||||
|
bool(getattr(self, "_claude_turn_speak", True)),
|
||||||
|
bool(getattr(self, "_claude_turn_converse", True)),
|
||||||
|
awaiting_reply)
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -1674,6 +1886,7 @@ class Agent:
|
|||||||
config_schema=arguments.get("config_schema") or None,
|
config_schema=arguments.get("config_schema") or None,
|
||||||
fast_patterns=arguments.get("fast_patterns") or None,
|
fast_patterns=arguments.get("fast_patterns") or None,
|
||||||
speak=bool(arguments.get("speak", False)),
|
speak=bool(arguments.get("speak", False)),
|
||||||
|
converse=bool(arguments.get("converse", False)),
|
||||||
author="aria",
|
author="aria",
|
||||||
)
|
)
|
||||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||||
@@ -1725,6 +1938,29 @@ class Agent:
|
|||||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||||
for s in items
|
for s in items
|
||||||
)
|
)
|
||||||
|
if name == "skill_get":
|
||||||
|
sk_name = (arguments.get("name") or "").strip()
|
||||||
|
if not sk_name:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
src = skills_mod.read_skill_source(sk_name)
|
||||||
|
if src is None:
|
||||||
|
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
|
||||||
|
m = src["manifest"]
|
||||||
|
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
|
||||||
|
meta = {
|
||||||
|
"name": m.get("name"), "description": m.get("description"),
|
||||||
|
"execution": m.get("execution"), "entry": m.get("entry"),
|
||||||
|
"active": m.get("active"), "args": m.get("args"),
|
||||||
|
"requires": m.get("requires"),
|
||||||
|
"config_schema": m.get("config_schema"),
|
||||||
|
"fast_patterns": m.get("fast_patterns"),
|
||||||
|
"speak": m.get("speak"), "converse": m.get("converse"),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
|
||||||
|
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
|
||||||
|
f"README:\n{src.get('readme') or '(leer)'}"
|
||||||
|
)
|
||||||
if name == "skill_update":
|
if name == "skill_update":
|
||||||
skill_name = (arguments.get("name") or "").strip()
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
@@ -1735,6 +1971,8 @@ class Agent:
|
|||||||
patch[k] = arguments[k]
|
patch[k] = arguments[k]
|
||||||
if "speak" in arguments and arguments["speak"] is not None:
|
if "speak" in arguments and arguments["speak"] is not None:
|
||||||
patch["speak"] = bool(arguments["speak"])
|
patch["speak"] = bool(arguments["speak"])
|
||||||
|
if "converse" in arguments and arguments["converse"] is not None:
|
||||||
|
patch["converse"] = bool(arguments["converse"])
|
||||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||||
patch["pip_packages"] = arguments["pip_packages"]
|
patch["pip_packages"] = arguments["pip_packages"]
|
||||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||||
@@ -1836,6 +2074,18 @@ class Agent:
|
|||||||
skill_name = cand_name
|
skill_name = cand_name
|
||||||
break
|
break
|
||||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||||
|
# Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer
|
||||||
|
# DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path)
|
||||||
|
# liest sie und steuert damit Vorlesen (speak) + 30s-Weiter-
|
||||||
|
# lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht
|
||||||
|
# gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False.
|
||||||
|
self._last_skill_flags = None
|
||||||
|
if res.get("ok"):
|
||||||
|
try:
|
||||||
|
_j = json.loads((res.get("stdout") or "").strip())
|
||||||
|
self._last_skill_flags = _j if isinstance(_j, dict) else None
|
||||||
|
except Exception:
|
||||||
|
self._last_skill_flags = None
|
||||||
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
|
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
|
||||||
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
|
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
|
||||||
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte
|
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
agent = agent_factory()
|
agent = agent_factory()
|
||||||
reply, _, _ = agent.chat(prompt, source="trigger")
|
reply, _, _, _, _ = agent.chat(prompt, source="trigger")
|
||||||
events = agent.pop_events()
|
events = agent.pop_events()
|
||||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||||
|
|||||||
+10
-1
@@ -636,6 +636,13 @@ class ChatOut(BaseModel):
|
|||||||
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
|
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
|
||||||
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
|
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
|
||||||
speak: bool = True
|
speak: bool = True
|
||||||
|
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
|
||||||
|
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
|
||||||
|
converse: bool = True
|
||||||
|
# Stellt ARIA eine blockierende Rueckfrage (braucht Stefans Antwort, bevor der
|
||||||
|
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
|
||||||
|
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
|
||||||
|
awaiting_reply: bool = False
|
||||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||||
# UI landet.
|
# UI landet.
|
||||||
@@ -719,7 +726,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
|||||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
reply, answered_by, speak = await loop.run_in_executor(
|
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: a.chat(
|
lambda: a.chat(
|
||||||
body.message, source=body.source, project_id=pid,
|
body.message, source=body.source, project_id=pid,
|
||||||
@@ -743,6 +750,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
|||||||
project_id=pid,
|
project_id=pid,
|
||||||
answered_by=answered_by,
|
answered_by=answered_by,
|
||||||
speak=speak,
|
speak=speak,
|
||||||
|
converse=converse,
|
||||||
|
awaiting_reply=awaiting_reply,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_project_pending[pid] = [
|
_project_pending[pid] = [
|
||||||
|
|||||||
+52
-4
@@ -75,6 +75,16 @@ _TOOL_HINTS = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
|
||||||
|
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
|
||||||
|
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
|
||||||
|
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
|
||||||
|
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
|
||||||
|
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
|
||||||
|
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
|
||||||
|
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
|
||||||
|
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
|
||||||
|
|
||||||
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
|
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
|
||||||
_HARD_HINTS = re.compile(
|
_HARD_HINTS = re.compile(
|
||||||
r"```|" # Codeblock
|
r"```|" # Codeblock
|
||||||
@@ -86,6 +96,18 @@ _HARD_HINTS = re.compile(
|
|||||||
|
|
||||||
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
||||||
|
|
||||||
|
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
|
||||||
|
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
|
||||||
|
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
|
||||||
|
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
|
||||||
|
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
|
||||||
|
_FORCE_CLAUDE = re.compile(
|
||||||
|
r"\b(clodi|clody)\b"
|
||||||
|
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
|
||||||
|
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _strip_leading_hint_blocks(text: str) -> str:
|
def _strip_leading_hint_blocks(text: str) -> str:
|
||||||
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
||||||
@@ -108,6 +130,10 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
|||||||
msg = _strip_leading_hint_blocks(user_message)
|
msg = _strip_leading_hint_blocks(user_message)
|
||||||
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
||||||
return False
|
return False
|
||||||
|
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
|
||||||
|
if _FORCE_CLAUDE.search(msg):
|
||||||
|
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
|
||||||
|
return False
|
||||||
if _TOOL_HINTS.search(msg):
|
if _TOOL_HINTS.search(msg):
|
||||||
return False
|
return False
|
||||||
if _HARD_HINTS.search(msg):
|
if _HARD_HINTS.search(msg):
|
||||||
@@ -122,10 +148,16 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
|||||||
# kein volles Memory (B1a).
|
# kein volles Memory (B1a).
|
||||||
|
|
||||||
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
||||||
|
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
|
||||||
|
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
|
||||||
|
# Aktion braucht, gehoert ans grosse Modell.
|
||||||
_AWARENESS = (
|
_AWARENESS = (
|
||||||
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
|
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
|
||||||
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
|
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
|
||||||
"sowie tiefe/technische Analysen und langen Code."
|
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
|
||||||
|
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
|
||||||
|
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
|
||||||
|
"und langen Code. Fuer ALL das eskalierst du."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,10 +185,26 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
|
|||||||
"GPS-Hinweis in der Nachricht.",
|
"GPS-Hinweis in der Nachricht.",
|
||||||
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
|
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
|
||||||
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
|
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
|
||||||
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).",
|
"- `run_*`-Skills: konkrete Faehigkeiten (z.B. Musik/Spotify u.a.). "
|
||||||
|
"Was ein Skill kann + welche Parameter er nimmt, steht in SEINER "
|
||||||
|
"Tool-Beschreibung — LIES sie und nutze den Skill fuer ALLES was "
|
||||||
|
"dazu passt (nicht nur die offensichtlichen Faelle). Nie selbst eine "
|
||||||
|
"Aktion 'spielen'/'nachschauen', wenn ein Skill das kann.",
|
||||||
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
|
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
|
||||||
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
|
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
|
||||||
"Bedarf; erfinde keine.",
|
"Bedarf; erfinde keine.",
|
||||||
|
"ANTI-HALLUZINATION (kritisch): Behaupte NIEMALS eine Aktion als "
|
||||||
|
"erledigt, ohne das Werkzeug WIRKLICH aufgerufen zu haben. Keine "
|
||||||
|
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
|
||||||
|
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
|
||||||
|
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
|
||||||
|
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
|
||||||
|
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
|
||||||
|
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
|
||||||
|
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
|
||||||
|
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
|
||||||
|
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
|
||||||
|
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
|
||||||
]
|
]
|
||||||
parts += [
|
parts += [
|
||||||
"",
|
"",
|
||||||
|
|||||||
+30
-3
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_skill_source(name: str) -> Optional[dict]:
|
||||||
|
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
|
||||||
|
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
|
||||||
|
der bestehende Funktionen killt)."""
|
||||||
|
m = read_manifest(name)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
d = _skill_dir(name)
|
||||||
|
entry = m.get("entry", "run.sh")
|
||||||
|
try:
|
||||||
|
code = (d / entry).read_text(encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
|
||||||
|
try:
|
||||||
|
readme = (d / "README.md").read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
readme = ""
|
||||||
|
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
|
||||||
|
|
||||||
|
|
||||||
def write_manifest(name: str, manifest: dict) -> None:
|
def write_manifest(name: str, manifest: dict) -> None:
|
||||||
d = _skill_dir(name)
|
d = _skill_dir(name)
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -166,6 +186,7 @@ def create_skill(
|
|||||||
config_schema: Optional[list] = None,
|
config_schema: Optional[list] = None,
|
||||||
fast_patterns: Optional[list] = None,
|
fast_patterns: Optional[list] = None,
|
||||||
speak: bool = False,
|
speak: bool = False,
|
||||||
|
converse: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||||
|
|
||||||
@@ -218,9 +239,12 @@ def create_skill(
|
|||||||
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||||
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
|
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
|
||||||
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
|
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
|
||||||
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen +
|
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
|
||||||
# Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung.
|
# converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False
|
||||||
|
# (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im
|
||||||
|
# JSON-Output pro Aufruf ueberschreiben (gemischte Skills).
|
||||||
"speak": bool(speak),
|
"speak": bool(speak),
|
||||||
|
"converse": bool(converse),
|
||||||
"version_history": [],
|
"version_history": [],
|
||||||
}
|
}
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
@@ -341,12 +365,15 @@ def update_skill(name: str, patch: dict) -> dict:
|
|||||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||||
manifest = read_manifest(name) or manifest
|
manifest = read_manifest(name) or manifest
|
||||||
|
|
||||||
allowed = {"description", "args", "requires", "active", "version", "entry", "speak"}
|
allowed = {"description", "args", "requires", "active", "version", "entry",
|
||||||
|
"speak", "converse"}
|
||||||
for k, v in patch.items():
|
for k, v in patch.items():
|
||||||
if k in allowed:
|
if k in allowed:
|
||||||
manifest[k] = v
|
manifest[k] = v
|
||||||
if "speak" in patch:
|
if "speak" in patch:
|
||||||
manifest["speak"] = bool(patch["speak"])
|
manifest["speak"] = bool(patch["speak"])
|
||||||
|
if "converse" in patch:
|
||||||
|
manifest["converse"] = bool(patch["converse"])
|
||||||
if "config_schema" in patch:
|
if "config_schema" in patch:
|
||||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||||
if "fast_patterns" in patch:
|
if "fast_patterns" in patch:
|
||||||
|
|||||||
+260
-9
@@ -16,6 +16,7 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -32,6 +33,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
@@ -293,10 +295,17 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# <voice>...</voice> wenn vorhanden → nur das nehmen
|
# <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
|
||||||
|
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
|
||||||
|
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
|
||||||
|
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
|
||||||
|
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
|
||||||
|
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
|
||||||
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
|
||||||
if voice_match:
|
if voice_match and voice_match.group(1).strip():
|
||||||
text = voice_match.group(1)
|
text = voice_match.group(1)
|
||||||
|
else:
|
||||||
|
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
|
||||||
|
|
||||||
t = text
|
t = text
|
||||||
|
|
||||||
@@ -370,6 +379,37 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
return t.strip()
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
|
||||||
|
|
||||||
|
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
|
||||||
|
# gerundeter Bearing / 45 (mod 8).
|
||||||
|
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
|
||||||
|
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""Distanz in Metern zwischen zwei GPS-Punkten."""
|
||||||
|
r = 6371000.0
|
||||||
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dp = math.radians(lat2 - lat1)
|
||||||
|
dl = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||||
|
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
|
||||||
|
|
||||||
|
|
||||||
|
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
|
||||||
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dl = math.radians(lon2 - lon1)
|
||||||
|
y = math.sin(dl) * math.cos(p2)
|
||||||
|
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||||||
|
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def _heading_word(bearing: float) -> str:
|
||||||
|
return _COMPASS_ADV[round(bearing / 45.0) % 8]
|
||||||
|
|
||||||
|
|
||||||
# ── STT Engine ───────────────────────────────────────────────
|
# ── STT Engine ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1175,8 +1215,28 @@ class ARIABridge:
|
|||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
"lon": float(lon),
|
"lon": float(lon),
|
||||||
})
|
})
|
||||||
|
lat, lon = float(lat), float(lon)
|
||||||
|
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
|
||||||
|
self._last_location = {"lat": lat, "lon": lon}
|
||||||
|
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
|
||||||
|
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
|
||||||
|
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
|
||||||
|
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
|
||||||
|
MIN_MOVE_M = 25.0
|
||||||
|
anchor = getattr(self, "_heading_anchor", None)
|
||||||
|
if anchor:
|
||||||
|
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
|
||||||
|
if moved >= MIN_MOVE_M:
|
||||||
|
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
|
||||||
|
self._last_heading = _heading_word(_bg)
|
||||||
|
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
|
||||||
|
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||||
|
else:
|
||||||
|
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
|
||||||
|
self._maybe_reverse_geocode(float(lat), float(lon))
|
||||||
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
||||||
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
||||||
try:
|
try:
|
||||||
@@ -1184,6 +1244,150 @@ class ARIABridge:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
|
||||||
|
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
|
||||||
|
(~1 km Raster) und im Hintergrund. Ergebnis landet in
|
||||||
|
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
|
||||||
|
try:
|
||||||
|
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if key == getattr(self, "_geocoded_key", None):
|
||||||
|
return # diese Gegend schon aufgeloest
|
||||||
|
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
|
||||||
|
# Stadt bis der neue Geocode da ist.
|
||||||
|
self._geocoded_key = key
|
||||||
|
self._last_place_name = ""
|
||||||
|
try:
|
||||||
|
self._geocode_task = asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
|
||||||
|
except Exception:
|
||||||
|
self._geocode_task = None
|
||||||
|
|
||||||
|
async def _ensure_place_name(self, lat, lon) -> None:
|
||||||
|
"""Stellt (blockierend, mit Timeout) sicher, dass der Ortsname fuer die
|
||||||
|
aktuelle Position da ist — fuer die ERSTE Nachricht an einem neuen Ort,
|
||||||
|
wo der Hintergrund-Geocode noch laeuft. Gecacht → danach instant."""
|
||||||
|
try:
|
||||||
|
lat, lon = float(lat), float(lon)
|
||||||
|
key = (round(lat, 3), round(lon, 3))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if key == getattr(self, "_geocoded_key", None) and getattr(self, "_last_place_name", ""):
|
||||||
|
return # schon aufgeloest
|
||||||
|
if key != getattr(self, "_geocoded_key", None):
|
||||||
|
self._maybe_reverse_geocode(lat, lon) # startet Task
|
||||||
|
task = getattr(self, "_geocode_task", None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=6)
|
||||||
|
except Exception:
|
||||||
|
pass # Timeout/Fehler → Praefix faellt auf reine Koordinaten zurueck
|
||||||
|
|
||||||
|
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
|
||||||
|
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
|
||||||
|
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
|
||||||
|
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
|
||||||
|
(Overpass-Milestone). Setzt self._last_place_name. Fehler → still
|
||||||
|
(Praefix faellt auf reine Koordinaten zurueck)."""
|
||||||
|
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
|
||||||
|
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
|
||||||
|
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
|
||||||
|
|
||||||
|
def _fetch():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
# Nominatim verlangt einen aussagekraeftigen User-Agent.
|
||||||
|
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
addr = data.get("address") or {}
|
||||||
|
extra = data.get("extratags") or {}
|
||||||
|
road = addr.get("road") or ""
|
||||||
|
house = addr.get("house_number") or ""
|
||||||
|
city = (addr.get("city") or addr.get("town") or addr.get("village")
|
||||||
|
or addr.get("municipality") or addr.get("county") or "")
|
||||||
|
plz = addr.get("postcode") or ""
|
||||||
|
state = addr.get("state") or ""
|
||||||
|
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
|
||||||
|
|
||||||
|
# Strasse + Hausnummer
|
||||||
|
street = (f"{road} {house}".strip()) if road else ""
|
||||||
|
# PLZ + Ort
|
||||||
|
citypart = (f"{plz} {city}".strip()) if plz else city
|
||||||
|
|
||||||
|
segs: list[str] = []
|
||||||
|
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
|
||||||
|
is_road = bool(ref) or any(w in road.lower()
|
||||||
|
for w in ("autobahn", "bundesstrasse", "bundesstraße",
|
||||||
|
"kreisstrasse", "kreisstraße"))
|
||||||
|
if is_road:
|
||||||
|
label = ref or road
|
||||||
|
km = await self._overpass_km(lat, lon)
|
||||||
|
if km:
|
||||||
|
label = f"{label} bei km {km}"
|
||||||
|
if label:
|
||||||
|
segs.append(label)
|
||||||
|
if street:
|
||||||
|
segs.append(street)
|
||||||
|
if citypart:
|
||||||
|
segs.append(citypart)
|
||||||
|
if state and (state not in citypart):
|
||||||
|
segs.append(state)
|
||||||
|
|
||||||
|
name = ", ".join(s for s in segs if s)
|
||||||
|
if not name:
|
||||||
|
name = data.get("name") or ""
|
||||||
|
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
|
||||||
|
if name and getattr(self, "_geocoded_key", None) == key:
|
||||||
|
self._last_place_name = name
|
||||||
|
logger.info("[geo] %.5f,%.5f → %s", lat, lon, name)
|
||||||
|
|
||||||
|
async def _overpass_km(self, lat: float, lon: float) -> str:
|
||||||
|
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
|
||||||
|
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
|
||||||
|
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
|
||||||
|
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
|
||||||
|
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
|
||||||
|
query = (f"[out:json][timeout:8];"
|
||||||
|
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
|
||||||
|
|
||||||
|
def _fetch():
|
||||||
|
try:
|
||||||
|
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(ov, data=body, headers={
|
||||||
|
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=12) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return ""
|
||||||
|
best_dist = ""
|
||||||
|
best_sq = 1e18
|
||||||
|
for e in (d.get("elements") or []):
|
||||||
|
tags = e.get("tags") or {}
|
||||||
|
dist = tags.get("distance") or tags.get("milestone:distance") or ""
|
||||||
|
elat, elon = e.get("lat"), e.get("lon")
|
||||||
|
if not dist or elat is None or elon is None:
|
||||||
|
continue
|
||||||
|
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
|
||||||
|
if sq < best_sq:
|
||||||
|
best_sq = sq
|
||||||
|
best_dist = str(dist)
|
||||||
|
return best_dist.replace(".", ",") if best_dist else ""
|
||||||
|
|
||||||
async def _trigger_brain_check_now(self) -> None:
|
async def _trigger_brain_check_now(self) -> None:
|
||||||
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
||||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
@@ -1356,6 +1560,13 @@ class ARIABridge:
|
|||||||
"text": display_text,
|
"text": display_text,
|
||||||
"sender": "aria",
|
"sender": "aria",
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
|
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
|
||||||
|
# der Anhang live nur als separates file_from_aria-Event (eigene
|
||||||
|
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
|
||||||
|
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
|
||||||
|
# Struktur wie im Backup, damit App live == Reload rendert.
|
||||||
|
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||||
|
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||||
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
||||||
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
|
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
|
||||||
"backupTs": assistant_backup_ts,
|
"backupTs": assistant_backup_ts,
|
||||||
@@ -1371,6 +1582,13 @@ class ARIABridge:
|
|||||||
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
|
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
|
||||||
# nach einem Fast-Path-Befehl grau haengen.
|
# nach einem Fast-Path-Befehl grau haengen.
|
||||||
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True,
|
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True,
|
||||||
|
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
|
||||||
|
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
|
||||||
|
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True,
|
||||||
|
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App
|
||||||
|
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
|
||||||
|
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
|
||||||
|
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
|
||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
@@ -1557,14 +1775,33 @@ class ARIABridge:
|
|||||||
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||||
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
|
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
|
||||||
)
|
)
|
||||||
if location and isinstance(location, dict):
|
# Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
|
||||||
lat = location.get("lat")
|
# (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
|
||||||
lon = location.get("lon") or location.get("lng")
|
loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
|
||||||
|
else getattr(self, "_last_location", None)
|
||||||
|
if isinstance(loc, dict):
|
||||||
|
lat = loc.get("lat")
|
||||||
|
lon = loc.get("lon") or loc.get("lng")
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
|
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
|
||||||
|
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
|
||||||
|
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
|
||||||
|
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
|
||||||
|
place = getattr(self, "_last_place_name", "")
|
||||||
|
where = f"{place} " if place else ""
|
||||||
|
heading = getattr(self, "_last_heading", "")
|
||||||
|
bearing = getattr(self, "_last_bearing", None)
|
||||||
|
if heading and bearing is not None:
|
||||||
|
moving = f", unterwegs {heading} ({bearing} Grad)"
|
||||||
|
elif heading:
|
||||||
|
moving = f", unterwegs {heading}"
|
||||||
|
else:
|
||||||
|
moving = ""
|
||||||
parts.append(
|
parts.append(
|
||||||
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
|
f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
|
||||||
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
|
f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
|
||||||
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
|
f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
|
||||||
|
f"nicht von dir aus, ausser er fragt explizit danach.]"
|
||||||
)
|
)
|
||||||
if parts:
|
if parts:
|
||||||
return " ".join(parts) + " " + text
|
return " ".join(parts) + " " + text
|
||||||
@@ -1712,6 +1949,12 @@ class ARIABridge:
|
|||||||
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
|
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
|
||||||
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
|
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
|
||||||
speak = data.get("speak", True)
|
speak = data.get("speak", True)
|
||||||
|
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
|
||||||
|
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
|
||||||
|
converse = data.get("converse", True)
|
||||||
|
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
|
||||||
|
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
|
||||||
|
awaiting_reply = bool(data.get("awaiting_reply", False))
|
||||||
|
|
||||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||||
# damit sie in der UI vor der Reply auftauchen
|
# damit sie in der UI vor der Reply auftauchen
|
||||||
@@ -1780,7 +2023,9 @@ class ARIABridge:
|
|||||||
try:
|
try:
|
||||||
await self._process_core_response(reply, {"projectId": turn_project_id,
|
await self._process_core_response(reply, {"projectId": turn_project_id,
|
||||||
"answeredBy": answered_by,
|
"answeredBy": answered_by,
|
||||||
"speak": speak})
|
"speak": speak,
|
||||||
|
"converse": converse,
|
||||||
|
"awaiting_reply": awaiting_reply})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[brain] _process_core_response Fehler")
|
logger.exception("[brain] _process_core_response Fehler")
|
||||||
await self._emit_activity("idle", "", project_id=project_id)
|
await self._emit_activity("idle", "", project_id=project_id)
|
||||||
@@ -2087,6 +2332,9 @@ class ARIABridge:
|
|||||||
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
||||||
str(payload.get("projectId") or "") or "(main)", text[:80])
|
str(payload.get("projectId") or "") or "(main)", text[:80])
|
||||||
else:
|
else:
|
||||||
|
_lg = location or getattr(self, "_last_location", None)
|
||||||
|
if isinstance(_lg, dict) and _lg.get("lat") is not None:
|
||||||
|
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
|
||||||
core_text = self._build_core_text(text, interrupted, location)
|
core_text = self._build_core_text(text, interrupted, location)
|
||||||
logger.info("[rvs] App-Chat%s%s: '%s'",
|
logger.info("[rvs] App-Chat%s%s: '%s'",
|
||||||
" [BARGE-IN]" if interrupted else "",
|
" [BARGE-IN]" if interrupted else "",
|
||||||
@@ -3184,6 +3432,9 @@ class ARIABridge:
|
|||||||
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
|
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
|
||||||
|
|
||||||
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
||||||
|
_lg = location or getattr(self, "_last_location", None)
|
||||||
|
if isinstance(_lg, dict) and _lg.get("lat") is not None:
|
||||||
|
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
|
||||||
core_text = self._build_core_text(text, interrupted, location)
|
core_text = self._build_core_text(text, interrupted, location)
|
||||||
await self.send_to_core(core_text,
|
await self.send_to_core(core_text,
|
||||||
source="app-voice" + (" [barge-in]" if interrupted else ""),
|
source="app-voice" + (" [barge-in]" if interrupted else ""),
|
||||||
|
|||||||
+89
-7
@@ -320,6 +320,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="diag-queue-banner" style="display:none;padding:6px 10px;background:#2A2410;border:1px solid #4A3F14;border-radius:6px;margin-bottom:6px;"></div>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
|
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
|
||||||
📎
|
📎
|
||||||
@@ -1425,6 +1426,12 @@
|
|||||||
let diagQueueStatus = {};
|
let diagQueueStatus = {};
|
||||||
let diagProjectsCache = [];
|
let diagProjectsCache = [];
|
||||||
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
|
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
|
||||||
|
// ── Pro-Kontext-Queue + Zustandsautomat (spiegelt die App) ──
|
||||||
|
const diagCtxStates = {}; // pid → 'idle' | 'running' | 'awaiting_reply'
|
||||||
|
const diagCtxQueues = {}; // pid → [{id,text}]
|
||||||
|
const diagDrafts = JSON.parse(localStorage.getItem('diag_ctx_drafts') || '{}');
|
||||||
|
let diagQid = 0;
|
||||||
|
const diagState = (pid) => diagCtxStates[pid] || 'idle';
|
||||||
|
|
||||||
function updateChatVisibilityByFocus() {
|
function updateChatVisibilityByFocus() {
|
||||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
@@ -1438,10 +1445,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchDiagFocus(id) {
|
function switchDiagFocus(id) {
|
||||||
focusedContextId = id || '';
|
const nextId = id || '';
|
||||||
|
// Pro-Kontext-Textfeld-Entwuerfe: Feldinhalt dem verlassenen Kontext
|
||||||
|
// zuordnen, Entwurf des neuen laden.
|
||||||
|
if (nextId !== focusedContextId) {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
if (input) {
|
||||||
|
diagDrafts[focusedContextId] = input.value;
|
||||||
|
input.value = diagDrafts[nextId] || '';
|
||||||
|
}
|
||||||
|
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||||
|
}
|
||||||
|
focusedContextId = nextId;
|
||||||
localStorage.setItem('diag_focused_context_id', focusedContextId);
|
localStorage.setItem('diag_focused_context_id', focusedContextId);
|
||||||
updateChatVisibilityByFocus();
|
updateChatVisibilityByFocus();
|
||||||
renderContextStrip();
|
renderContextStrip();
|
||||||
|
renderDiagQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContextStrip() {
|
function renderContextStrip() {
|
||||||
@@ -1983,6 +2002,15 @@
|
|||||||
projectId: p.projectId || '',
|
projectId: p.projectId || '',
|
||||||
answeredBy: p.answeredBy || '',
|
answeredBy: p.answeredBy || '',
|
||||||
});
|
});
|
||||||
|
// ── Pro-Kontext-Queue-Automat: auf ARIAs Antwort reagieren ──
|
||||||
|
if (sender === 'aria') {
|
||||||
|
const apid = p.projectId || '';
|
||||||
|
const st = diagState(apid);
|
||||||
|
if (st === 'running' || st === 'awaiting_reply') {
|
||||||
|
if (p.awaiting_reply) { diagCtxStates[apid] = 'awaiting_reply'; renderDiagQueue(); }
|
||||||
|
else advanceDiagQueue(apid);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'chat_message_deleted') {
|
if (msg.type === 'chat_message_deleted') {
|
||||||
@@ -2179,16 +2207,64 @@
|
|||||||
renderDiagPending();
|
renderDiagPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pro-Kontext-Queue: Helfer (spiegelt die App) ──
|
||||||
|
function actuallySendDiag(pid, text) {
|
||||||
|
addChat('sent', text, 'via RVS', { projectId: pid });
|
||||||
|
send({ action: 'test_rvs', text, projectId: pid });
|
||||||
|
diagCtxStates[pid] = 'running';
|
||||||
|
renderDiagQueue();
|
||||||
|
}
|
||||||
|
function advanceDiagQueue(pid) {
|
||||||
|
const q = diagCtxQueues[pid] || [];
|
||||||
|
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
|
||||||
|
const next = q.shift();
|
||||||
|
actuallySendDiag(pid, next.text);
|
||||||
|
}
|
||||||
|
function enqueueOrSendDiag(pid, text) {
|
||||||
|
if (diagState(pid) === 'running') {
|
||||||
|
(diagCtxQueues[pid] = diagCtxQueues[pid] || []).push({ id: 'q' + (++diagQid), text });
|
||||||
|
renderDiagQueue();
|
||||||
|
} else {
|
||||||
|
// idle ODER awaiting_reply → sofort (bei awaiting = Antwort auf Rueckfrage).
|
||||||
|
actuallySendDiag(pid, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeDiagQueuedFocused(id) {
|
||||||
|
const pid = focusedContextId;
|
||||||
|
diagCtxQueues[pid] = (diagCtxQueues[pid] || []).filter(it => it.id !== id);
|
||||||
|
renderDiagQueue();
|
||||||
|
}
|
||||||
|
function renderDiagQueue() {
|
||||||
|
const el = document.getElementById('diag-queue-banner');
|
||||||
|
if (!el) return;
|
||||||
|
const st = diagState(focusedContextId);
|
||||||
|
const q = diagCtxQueues[focusedContextId] || [];
|
||||||
|
if (st !== 'awaiting_reply' && q.length === 0) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||||||
|
el.style.display = 'block';
|
||||||
|
let html = '';
|
||||||
|
if (st === 'awaiting_reply') {
|
||||||
|
html += '<div style="color:#FFD60A;font-weight:600;margin-bottom:4px;">❓ ARIA fragt nach — deine Eingabe beantwortet das</div>';
|
||||||
|
}
|
||||||
|
if (q.length) {
|
||||||
|
html += q.map(it =>
|
||||||
|
'<span style="display:inline-block;background:#1E1E2E;border:1px solid #4A3F14;border-radius:10px;padding:2px 8px;margin:2px;font-size:12px;color:#FFD60A;">⏸ ' +
|
||||||
|
escapeHtml(it.text.slice(0, 40)) +
|
||||||
|
' <a href="#" style="color:#FF6B6B;text-decoration:none;" onclick="removeDiagQueuedFocused(\'' + it.id + '\');return false;">✕</a></span>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function testRVS() {
|
function testRVS() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text && diagPendingFiles.length === 0) return;
|
if (!text && diagPendingFiles.length === 0) return;
|
||||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||||
if (text) {
|
if (text) {
|
||||||
// Multi-Threading: mit fokussierter Kontext-ID senden.
|
// Draft dieses Kontexts leeren + Queue-Automat entscheidet senden/anstellen.
|
||||||
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
|
diagDrafts[focusedContextId] = '';
|
||||||
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
|
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||||
send({ action: 'test_rvs', text, projectId: focusedContextId });
|
enqueueOrSendDiag(focusedContextId, text);
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
@@ -2675,8 +2751,8 @@
|
|||||||
const input = document.getElementById('chat-input-fs');
|
const input = document.getElementById('chat-input-fs');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
addChat('sent', text, 'via RVS');
|
// Ueber denselben Queue-Automaten wie der Haupt-Chat (fokussierter Kontext).
|
||||||
send({ action: 'test_rvs', text });
|
enqueueOrSendDiag(focusedContextId, text);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
// Escape schliesst Vollbild-Chat
|
// Escape schliesst Vollbild-Chat
|
||||||
@@ -3650,6 +3726,12 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
|
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
|
||||||
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
|
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
|
||||||
|
// Entwurf des zuletzt fokussierten Kontexts ins Feld laden + Queue-Banner init.
|
||||||
|
(function initDiagDraft() {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
if (input && diagDrafts[focusedContextId]) input.value = diagDrafts[focusedContextId];
|
||||||
|
renderDiagQueue();
|
||||||
|
})();
|
||||||
|
|
||||||
// Escape schliesst Lightbox
|
// Escape schliesst Lightbox
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user