From 20123de827cd8517270f6b635386f1c4d0f9ea5a Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 3 May 2026 21:40:15 +0200 Subject: [PATCH] fix: Sprachnachricht-Bubble defensiv + Bild+Text als eine Anfrage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2: STT-Result schreibt jetzt eine neue User-Bubble wenn keine Placeholder im State gefunden wird (statt das Update zu verwerfen). Schuetzt vor Race-Conditions zwischen audio-send und State-Updates, damit der gesprochene Text immer im Chat erscheint. Bug 3: Bild + Text wurden als zwei getrennte Events ('file' + 'chat') gesendet, jeder triggerte einen eigenen send_to_core. ARIA antwortete zweimal — einmal "warte auf Anweisung" beim Bild, dann nochmal auf den Text. Bridge buffert jetzt eingehende file-Events 800ms; kommt in dem Fenster ein chat, werden alle Files + Text zu einer einzigen aria-core-Nachricht gemerged. Kein chat → Files alleine wie bisher. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 17 +++- bridge/aria_bridge.py | 145 +++++++++++++++++++---------- 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 0f64349..be62d97 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -281,9 +281,22 @@ const ChatScreen: React.FC = () => { const idx = prev.findIndex(m => m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') ); - if (idx < 0) return prev; + const newText = `\uD83C\uDFA4 ${sttText}`; + if (idx < 0) { + // Defensiv: wenn keine Placeholder im State (z.B. weil sie nie + // hinzugefuegt wurde oder schon durch ein anderes Update verloren + // ging), die Sprachnachricht trotzdem als neue Bubble einfuegen. + // Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht. + return capMessages([...prev, { + id: nextId(), + sender: 'user', + text: newText, + timestamp: message.timestamp, + attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], + }]); + } const next = prev.slice(); - next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` }; + next[idx] = { ...next[idx], text: newText }; return next; }); } diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index c5a6c01..42cf0f7 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -551,6 +551,15 @@ class ARIABridge: # Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger, # weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann. self._remote_stt_ready: bool = False + # Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen + # zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files + # kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen + # Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf + # Anweisung" beim file, einmal auf den Chat-Text). + # Liste von Tuples: (file_path, name, file_type, size_kb, width, height) + self._pending_files: list[tuple[str, str, str, int, int, int]] = [] + self._pending_files_flush_task: Optional[asyncio.Task] = None + self._PENDING_FILES_WINDOW_SEC: float = 0.8 def initialize(self) -> None: """Initialisiert alle Komponenten. @@ -1019,6 +1028,51 @@ class ARIABridge: except Exception as e: logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key) + def _build_pending_files_message(self, user_text: str) -> str: + """Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem + User-Text. user_text leer → 'warte auf Anweisung'-Variante.""" + parts: list[str] = [] + for fp, name, ftype, kb, w, h in self._pending_files: + dim = f" {w}x{h}px" if (w and h) else "" + kind = "Bild" if ftype.startswith("image/") else "Datei" + parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}") + files_summary = "\n".join(parts) + n = len(self._pending_files) + anhang = "Anhang" if n == 1 else "Anhaenge" + if user_text: + return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n" + f"Er sagt dazu: \"{user_text}\"") + return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n" + f"Warte auf seine Anweisung was du damit tun sollst.") + + async def _flush_pending_files_after(self, delay: float) -> None: + """Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an + aria-core senden ('warte auf Anweisung'-Variante).""" + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + if not self._pending_files: + return + text = self._build_pending_files_message("") + self._pending_files = [] + self._pending_files_flush_task = None + await self.send_to_core(text, source="app-file") + + async def _flush_pending_files_with_text(self, user_text: str) -> bool: + """Wenn ein chat-Text reinkommt waehrend Files gepuffert sind: + Files + Text zu einer einzigen aria-core-Nachricht mergen. + Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).""" + if not self._pending_files: + return False + if self._pending_files_flush_task and not self._pending_files_flush_task.done(): + self._pending_files_flush_task.cancel() + self._pending_files_flush_task = None + text = self._build_pending_files_message(user_text) + self._pending_files = [] + await self.send_to_core(text, source="app-file+chat") + return True + async def send_to_core(self, text: str, source: str = "bridge") -> None: """Sendet Text an aria-core (OpenClaw chat.send Protokoll).""" if self.ws_core is None: @@ -1181,8 +1235,15 @@ class ARIABridge: except (TypeError, ValueError): self._next_speed_override = None if text: - logger.info("[rvs] App-Chat: '%s'", text[:80]) - await self.send_to_core(text, source="app") + # Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig + # gesendet), mergen wir sie zu einer einzigen Anfrage statt + # zwei separater send_to_core-Calls. + merged = await self._flush_pending_files_with_text(text) + if merged: + logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80]) + else: + logger.info("[rvs] App-Chat: '%s'", text[:80]) + await self.send_to_core(text, source="app") return if msg_type == "cancel_request": @@ -1341,70 +1402,54 @@ class ARIABridge: await self.ws_core.send(raw_message) elif msg_type == "file": - # Datei von der App → als Text-Nachricht an aria-core + # Datei von der App: speichern + zu Pending-Queue hinzufuegen. + # Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW) + # zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA + # zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat. file_name = payload.get("name", "unbekannt") file_type = payload.get("type", "") file_b64 = payload.get("base64", "") - file_size = payload.get("size", 0) width = payload.get("width", 0) height = payload.get("height", 0) logger.info("[rvs] Datei empfangen: %s (%s, %dKB)", file_name, file_type, len(file_b64) // 1365 if file_b64 else 0) - # Shared Volume: /shared/ ist in Bridge UND aria-core gemountet SHARED_DIR = "/shared/uploads" os.makedirs(SHARED_DIR, exist_ok=True) - if file_b64 and file_type.startswith("image/"): - # Bild in Shared Volume speichern + if not file_b64: + text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen." + await self.send_to_core(text, source="app-file") + return + + if file_type.startswith("image/"): ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png" safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}" file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext) - with open(file_path, "wb") as f: - f.write(base64.b64decode(file_b64)) - size_kb = len(file_b64) // 1365 - logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb) - # ERST an aria-core senden (wichtigster Schritt) - text = (f"Stefan hat dir ein Bild geschickt: {file_name}" - f"{f' ({width}x{height}px)' if width else ''}" - f", {size_kb}KB." - f" Das Bild liegt unter: {file_path}" - f" Warte auf Stefans Anweisung was du damit tun sollst.") - await self.send_to_core(text, source="app-file") - # Dann App informieren (optional, darf nicht crashen) - try: - await self._send_to_rvs({ - "type": "file_saved", - "payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type}, - "timestamp": int(asyncio.get_event_loop().time() * 1000), - }) - except Exception as e: - logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e) - elif file_b64: - # Andere Datei in Shared Volume speichern + else: safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}" file_path = os.path.join(SHARED_DIR, safe_name) - with open(file_path, "wb") as f: - f.write(base64.b64decode(file_b64)) - size_kb = len(file_b64) // 1365 - logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) - # ERST an aria-core senden - text = (f"Stefan hat dir eine Datei geschickt: {file_name}" - f" ({file_type}, {size_kb}KB)." - f" Die Datei liegt unter: {file_path}" - f" Warte auf Stefans Anweisung was du damit tun sollst.") - await self.send_to_core(text, source="app-file") - try: - await self._send_to_rvs({ - "type": "file_saved", - "payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type}, - "timestamp": int(asyncio.get_event_loop().time() * 1000), - }) - except Exception as e: - logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e) - else: - text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen." - await self.send_to_core(text, source="app-file") + with open(file_path, "wb") as f: + f.write(base64.b64decode(file_b64)) + size_kb = len(file_b64) // 1365 + logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) + + # In Pending-Queue + Flush-Timer (anti-spam Buffering) + self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0))) + if self._pending_files_flush_task and not self._pending_files_flush_task.done(): + self._pending_files_flush_task.cancel() + self._pending_files_flush_task = asyncio.create_task( + self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC) + ) + + try: + await self._send_to_rvs({ + "type": "file_saved", + "payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type}, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + except Exception as e: + logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e) elif msg_type == "file_request": # App fordert eine Datei an (Re-Download nach Cache-Leerung)