Compare commits

...
8 Commits
Author SHA1 Message Date
duffyduck 761217cb5a release: bump version to 0.2.1.6 2026-07-12 10:09:49 +02:00
duffyduckandClaude Opus 4.8 ff6d31acbd docs(readme): Pro-Projekt-Queue, Rueckfrage-Loop, Drafts + TTS-Abspiel-Queue
App-Features + Completed-Log aktualisiert; veraltetes Barge-In-Cancel-Verhalten
durch das neue Anstellen-statt-Abbrechen ersetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:07:18 +02:00
duffyduckandClaude Opus 4.8 2d7a5784c2 docs(changelog): 0.2.1.5 — Pro-Projekt-Queue mit Rueckfrage-Loop
Queue pro Projekt (anstellen statt abbrechen) + [[AWAIT]]-Rueckfrage-Loop,
Pro-Projekt-Textfeld-Entwuerfe, TTS-Abspiel-Queue (back-to-back sprechen
nacheinander), Voice bricht nicht mehr ab. App + Diagnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:05:08 +02:00
duffyduckandClaude Opus 4.8 fa871219ae feat(queue): TTS-Abspiel-Queue — back-to-back-Antworten sprechen nacheinander
Bisher war die serialisierte Sprachausgabe zweier fast gleichzeitig fertiger
Antworten Timing-Glueck: PcmStreamPlayer.start() ruft stopInternal() (flush+
release), eine neue Antwort haette die laufende also abgeschnitten, sobald ihr
Audio waehrend der Wiedergabe der ersten ankam.

Jetzt echte Abspiel-Queue im audioService: kommt eine neue HOERBARE Antwort
waehrend eine andere noch hoerbar spielt (pcmAudiblePlaying bis
PcmPlaybackFinished, nicht nur bis Stream-Ende), werden ihre PCM-Chunks
gepuffert und erst nach dem Drain der laufenden nachgespielt. Bei wartender
Antwort meldet PcmPlaybackFinished NICHT 'fertig' (kein Wake-Word-Re-Arm).
Harter Stop/Barge-In/Mute verwirft die Queue. Race gegen gleichzeitige Chunks
einer dritten Antwort geschlossen (Flags vor await gesetzt). onPcmCached meldet
den WAV-Pfad nachgespielter Antworten fuer Mund-Button-Replay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:03:31 +02:00
duffyduck e61a0ff871 release: bump version to 0.2.1.5 2026-07-12 09:44:34 +02:00
duffyduckandClaude Opus 4.8 c9a1d80696 feat(queue): Diagnostic spiegelt Pro-Kontext-Queue + Rueckfrage + Drafts
Diagnostic bekommt denselben Queue-Automaten wie die App: anstellen statt
abbrechen, awaiting_reply pausiert die Queue (naechste Eingabe beantwortet die
Rueckfrage), Queue-Banner mit loeschbaren Eintraegen ueber dem Eingabefeld,
und Pro-Kontext-Textfeld-Entwuerfe (Feldinhalt bleibt beim Kontextwechsel
erhalten). rvs_chat-Handler liest awaiting_reply aus der Bridge-Payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:41:24 +02:00
duffyduckandClaude Opus 4.8 8a0670a3d2 feat(queue): Pro-Projekt-Nachrichten-Queue mit Rueckfrage-Loop + Textfeld-Entwuerfe (App)
Zweite Nachricht waehrend ARIA arbeitet wird jetzt ANGESTELLT statt den
laufenden Task abzubrechen. Stellt ARIA eine blockierende Rueckfrage, pausiert
die Queue und die naechste Eingabe beantwortet sie — bis eine finale Antwort
kommt, dann laeuft der naechste Queue-Eintrag (pro Projekt unabhaengig).

- Brain: ARIA deklariert Rueckfragen per unsichtbarem [[AWAIT]]-Marker
  (wie speak/converse; kein '?'-Raten). _extract_await_marker strippt ihn,
  chat() gibt 5-Tupel (+awaiting_reply), System-Prompt erklaert den Marker.
- Bridge: awaiting_reply aus Brain-Response in die chat-Broadcast-Payload.
- App: app-lokale Queue + Zustandsautomat (idle/running/awaiting_reply) pro
  Projekt; Send-Flow von Abbruch auf Anstellen; Stop-Button (cancelRequest)
  schaltet die Queue weiter; sichtbare pending_queue-Bubbles (tippen loescht);
  Rueckfrage-/Queue-Banner ueber dem Eingabefeld. Voice bricht nicht mehr ab
  (haltet nur TTS, serialisiert im Brain-Lock); Text-Send erkennt Brain-busy
  als Fallback. Pro-Projekt-Textfeld-Entwuerfe (Draft-Map + AsyncStorage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:35:46 +02:00
duffyduckandClaude Opus 4.8 2d9c42a7ea docs(changelog): 0.2.1.1–0.2.1.4 nachgetragen — der große Tag
Vier Versionen aufgeholt: manifest.speak (0.2.1.1); Standort-Intelligenz +
speak/converse-pro-Aufruf + Anti-Halluzination (0.2.1.2); skill_get + converse-
Fast-Path + Voice-Bubble-Fix (0.2.1.3); tool-loses local + Info-Halluzination-
Guard + leeres-<voice>-Fix + Live-Datei-Anhang + TDZ- & QRScanner-Cleanup und
ARIAs live geschärfter Spotify-/yt-dlp-Skill (0.2.1.4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:13:42 +02:00
11 changed files with 583 additions and 78 deletions
+84
View File
@@ -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
### Hinzugefügt
+7 -1
View File
@@ -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
- **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.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (130 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
- **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
@@ -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
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.06.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
- **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.
- **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)
@@ -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] 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] **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] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20104
versionName "0.2.1.4"
versionCode 20106
versionName "0.2.1.6"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.1.4",
"version": "0.2.1.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+212 -59
View File
@@ -64,6 +64,14 @@ interface Attachment {
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 {
id: string;
sender: 'user' | 'aria';
@@ -131,7 +139,7 @@ interface ChatMessage {
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* 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). */
sendAttempts?: number;
}
@@ -275,6 +283,7 @@ async function checkFileExists(uri: string): Promise<boolean> {
const ChatScreen: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const inputTextRef = useRef('');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [showFileUpload, setShowFileUpload] = useState(false);
const [showCameraUpload, setShowCameraUpload] = useState(false);
@@ -301,6 +310,24 @@ const ChatScreen: React.FC = () => {
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
// Busy-Status des fokussierten Kontexts ohne stale Closure).
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 [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -509,14 +536,39 @@ const ChatScreen: React.FC = () => {
return () => unsub();
}, []);
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
// fuer den Auto-Fall angenehm.
// Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln.
useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
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]);
// 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
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
// die sich bei jedem Wechsel).
@@ -1236,6 +1288,18 @@ const ChatScreen: React.FC = () => {
: prev);
setAgentActivity(cur =>
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
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
@@ -1667,6 +1731,13 @@ const ChatScreen: React.FC = () => {
// das Mikro greifen kann.
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 () => {
unsubWake();
@@ -1675,6 +1746,7 @@ const ChatScreen: React.FC = () => {
unsubPassive();
unsubTtsStart();
unsubTtsEnd();
unsubPcmCached();
};
}, [wakeWordActive]);
@@ -1972,6 +2044,65 @@ const ChatScreen: React.FC = () => {
// --- 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
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
@@ -2002,34 +2133,22 @@ const ChatScreen: React.FC = () => {
setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
clearStuckWatchdog();
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-
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
// mach lieber X" sagen wie in einem echten Gespraech.
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// 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 speaking = audioService.isPlayingAudio();
// Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht
// 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 });
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
}
return true;
return false;
}, []);
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
@@ -2216,37 +2335,37 @@ const ChatScreen: React.FC = () => {
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]));
setInputText('');
// Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt).
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
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]);
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 ---
@@ -2546,7 +2665,16 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
) : null}
{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
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
@@ -2969,6 +3097,18 @@ const ChatScreen: React.FC = () => {
</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 */}
<View style={styles.inputContainer}>
{/* Datei-Buttons */}
@@ -3466,6 +3606,19 @@ const styles = StyleSheet.create({
fontSize: 14,
marginTop: 4,
},
queueBanner: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#2A2410',
borderTopWidth: 1,
borderTopColor: '#4A3F14',
},
queueBannerText: {
color: '#FFD60A',
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
+127 -1
View File
@@ -284,6 +284,13 @@ class AudioService {
private pcmSampleRate: number = 24000;
private pcmChannels: number = 1;
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 readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
@@ -374,6 +381,16 @@ class AudioService {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
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();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
@@ -1401,6 +1418,23 @@ class AudioService {
const base64 = payload.base64 || '';
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)
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
if (this.pcmStreamActive && !silent) {
@@ -1448,6 +1482,8 @@ class AudioService {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
this.pcmAudiblePlaying = true;
this.pcmPlayingMsgId = messageId;
}
}
@@ -1490,6 +1526,73 @@ class AudioService {
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. */
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
try {
@@ -1578,6 +1681,19 @@ class AudioService {
// Callback wenn alle Audio-Teile abgespielt sind
private playbackFinishedListeners: (() => 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 {
this.playbackFinishedListeners.push(callback);
@@ -1759,6 +1875,10 @@ class AudioService {
}
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
PcmStreamPlayer?.stop().catch(() => {});
// Wartende TTS-Antworten verwerfen (Mund-Button = still sein).
this.pcmPendingStreams = [];
this.pcmAudiblePlaying = false;
this.pcmPlayingMsgId = '';
stopBackgroundAudio().catch(() => {});
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
@@ -1770,7 +1890,8 @@ class AudioService {
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
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;
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
@@ -1804,6 +1925,11 @@ class AudioService {
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
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.
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er
+45 -4
View File
@@ -1164,6 +1164,22 @@ def _claims_live_media_state(text: str) -> bool:
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:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1588,7 +1604,8 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
return fast_reply, "fast-path", speak, converse
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source,
@@ -1606,7 +1623,8 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1653,6 +1671,23 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
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
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
@@ -1817,13 +1852,19 @@ class Agent:
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"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
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
# 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)))
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try:
agent = agent_factory()
reply, _, _, _ = agent.chat(prompt, source="trigger")
reply, _, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+6 -1
View File
@@ -639,6 +639,10 @@ class ChatOut(BaseModel):
# 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
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -722,7 +726,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse = await loop.run_in_executor(
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -747,6 +751,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
answered_by=answered_by,
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
)
finally:
_project_pending[pid] = [
+9 -1
View File
@@ -1585,6 +1585,10 @@ class ARIABridge:
# 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),
})
@@ -1948,6 +1952,9 @@ class ARIABridge:
# 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)
# damit sie in der UI vor der Reply auftauchen
@@ -2017,7 +2024,8 @@ class ARIABridge:
await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by,
"speak": speak,
"converse": converse})
"converse": converse,
"awaiting_reply": awaiting_reply})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)
+89 -7
View File
@@ -320,6 +320,7 @@
</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>
<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">
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
&#x1F4CE;
@@ -1425,6 +1426,12 @@
let diagQueueStatus = {};
let diagProjectsCache = [];
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() {
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
@@ -1438,10 +1445,22 @@
}
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);
updateChatVisibilityByFocus();
renderContextStrip();
renderDiagQueue();
}
function renderContextStrip() {
@@ -1983,6 +2002,15 @@
projectId: p.projectId || '',
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;
}
if (msg.type === 'chat_message_deleted') {
@@ -2179,16 +2207,64 @@
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;">&#x2753; ARIA fragt nach &mdash; 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;">&#x23F8; ' +
escapeHtml(it.text.slice(0, 40)) +
' <a href="#" style="color:#FF6B6B;text-decoration:none;" onclick="removeDiagQueuedFocused(\'' + it.id + '\');return false;">&#x2715;</a></span>'
).join('');
}
el.innerHTML = html;
}
function testRVS() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
// Multi-Threading: mit fokussierter Kontext-ID senden.
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
send({ action: 'test_rvs', text, projectId: focusedContextId });
// Draft dieses Kontexts leeren + Queue-Automat entscheidet senden/anstellen.
diagDrafts[focusedContextId] = '';
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
enqueueOrSendDiag(focusedContextId, text);
}
input.value = '';
}
@@ -2675,8 +2751,8 @@
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
// Ueber denselben Queue-Automaten wie der Haupt-Chat (fokussierter Kontext).
enqueueOrSendDiag(focusedContextId, text);
input.value = '';
}
// Escape schliesst Vollbild-Chat
@@ -3650,6 +3726,12 @@
}
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
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
document.addEventListener('keydown', (e) => {