From 5c07aef52629311a998f87f245f46ffd141c52e3 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 14 May 2026 23:14:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20Offline-Bubble=20verschwand=20nach?= =?UTF-8?q?=20Reconnect=20=E2=80=94=20clientMsgId-Dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Race-Bug nach Etappe 3: Beim Reconnect schickt die App parallel chat_history_request und (via flushQueuedMessages) die offline gestaute Nachricht. Die history_response kam an bevor die Bridge die Bubble in chat_backup.jsonl geschrieben hatte → Server-Liste ohne unsere Bubble → Merge ersetzte den lokalen Stand → Bubble weg (im Diagnostic war sie gleich danach drin). Bridge: _append_chat_backup nimmt clientMsgId mit auf. send_to_core reicht sie als kwarg durch (chat- und audio-Pfad). App: chat_history_response-Merge dedupt per clientMsgId. Lokale User- Bubbles deren clientMsgId der Server noch nicht kennt bleiben erhalten (localOnly-Filter erweitert). Server-User-Bubbles mit clientMsgId kriegen deliveryStatus='delivered' damit das ✓✓ auch nach Reload sichtbar bleibt. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 19 ++++++++++++++++++- bridge/aria_bridge.py | 25 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index b5427a7..14fccde 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -615,6 +615,10 @@ const ChatScreen: React.FC = () => { mimeType: f.mimeType || '', serverPath: f.serverPath || '', })) as Attachment[]; + // clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup, + // damit wir lokale Bubbles per ID dedupen koennen statt nur per + // Text/Timestamp-Heuristik. + const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined; return { id: nextId(), sender: role as 'user' | 'aria', @@ -622,19 +626,32 @@ const ChatScreen: React.FC = () => { timestamp: m.ts || Date.now(), attachments: attachments.length ? attachments : undefined, backupTs: typeof m.ts === 'number' ? m.ts : undefined, + ...(cmid && { clientMsgId: cmid }), + // Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓) + ...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }), }; }); const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0); setMessages(prev => { + // ClientMsgIds die der Server kennt — lokale Bubbles mit der + // gleichen ID werden durch die Server-Version ersetzt. + const serverCmids = new Set( + fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x) + ); // Lokal-only Bubbles erkennen + behalten: // - Skill-Created-Notifications (skillCreated gesetzt) // - Laufende Sprachnachrichten ohne STT-Result (audioRequestId // gesetzt UND text leer/Placeholder) + // - User-Bubbles deren clientMsgId der Server noch nicht kennt: + // z.B. waehrend Reconnect-Race oder solange flushQueuedMessages + // noch laeuft. Ohne diesen Schutz haette der history_response + // die gerade reaktivierten Offline-Nachrichten geloescht. const localOnly = prev.filter(m => m.skillCreated || m.triggerCreated || m.memorySaved || - (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) + (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) || + (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) ); // Server-Stand + lokal-only (chronologisch sortiert) const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp); diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index e8c4de9..ead3e79 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1319,7 +1319,7 @@ class ARIABridge: await self.send_to_core(text, source="app-file+chat") return True - async def send_to_core(self, text: str, source: str = "bridge") -> None: + async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None: """Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort. Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir @@ -1333,8 +1333,13 @@ class ARIABridge: logger.info("[brain] chat ← %s '%s'", source, text[:80]) # User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect - # / Diagnostic-Reload als History-Quelle gelesen. - self._append_chat_backup({"role": "user", "text": text, "source": source}) + # / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern + # damit die App beim chat_history_response ihre lokale Bubble + # dedupen kann (sonst verschwindet sie nach Offline→Online-Race). + entry: dict = {"role": "user", "text": text, "source": source} + if client_msg_id: + entry["clientMsgId"] = client_msg_id + self._append_chat_backup(entry) # agent_activity → thinking. _emit_activity statt direktem _send_to_rvs # damit der State-Cache fuer die spaetere idle-Dedup richtig steht. @@ -1634,7 +1639,9 @@ class ARIABridge: " [BARGE-IN]" if interrupted else "", " [GPS]" if location else "", text[:80]) - await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else "")) + await self.send_to_core(core_text, + source="app" + (" [barge-in]" if interrupted else ""), + client_msg_id=client_msg_id) return if msg_type == "cancel_request": @@ -2234,7 +2241,8 @@ class ARIABridge: " [GPS]" if location else "", f" reqId={audio_request_id[:16]}" if audio_request_id else "") asyncio.create_task(self._process_app_audio( - audio_b64, mime_type, interrupted, audio_request_id, location)) + audio_b64, mime_type, interrupted, audio_request_id, location, + client_msg_id=client_msg_id)) elif msg_type == "stt_response": # Antwort der whisper-bridge auf unseren stt_request @@ -2293,7 +2301,8 @@ class ARIABridge: async def _process_app_audio(self, audio_b64: str, mime_type: str, interrupted: bool = False, audio_request_id: str = "", - location: Optional[dict] = None) -> None: + location: Optional[dict] = None, + client_msg_id: Optional[str] = None) -> None: """App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal. interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat @@ -2349,7 +2358,9 @@ class ARIABridge: # Dann an Brain — der blockt synchron bis ARIA fertig ist. core_text = self._build_core_text(text, interrupted, location) - await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else "")) + await self.send_to_core(core_text, + source="app-voice" + (" [barge-in]" if interrupted else ""), + client_msg_id=client_msg_id) else: logger.info("[rvs] Keine Sprache erkannt — ignoriert")