feat: App-Chat-Sync — verpasste Nachrichten + chat_cleared Live-Update

Zwei zusammenhaengende Bugs:
  1. App aktualisierte nicht wenn die Diagnostic "Konversation komplett
     zuruecksetzen" gedrueckt hat — die App hatte den lokalen Stand weiter
  2. Nachrichten die kamen waehrend die App offline/geschlossen war,
     wurden nicht nachgeladen

Loesung: chat_backup.jsonl wird wieder geschrieben (Bridge statt Diagnostic,
weil OpenClaw-Code-Pfad tot ist) und dient als Server-Truth fuer App+Diagnostic.

bridge/aria_bridge.py
  _append_chat_backup() schreibt jeden Turn (User + ARIA) als JSONL-Zeile
  in /shared/config/chat_backup.jsonl. Trigger: send_to_core (User) +
  _process_core_response (Assistant, inkl. file-Attachments).

  _read_chat_backup_since(since_ms, limit) liest die Datei, filtert auf
  ts > since_ms, gibt max limit neueste zurueck. Honoriert file_deleted-Marker.

  Neuer RVS-Handler chat_history_request {since, limit?} → antwortet mit
  chat_history_response {messages: [...], since}.

diagnostic/server.js
  /api/chat-history-clear broadcastet jetzt zusaetzlich chat_cleared via
  RVS (sendToRVS_raw), damit App ihre lokale Liste auch leert. Vorher nur
  Browser-Clients via broadcast() — App war aussen vor.

rvs/server.js
  ALLOWED_TYPES um chat_history_request, chat_history_response, chat_cleared.

android/src/screens/ChatScreen.tsx
  - Bei (re)connect: AsyncStorage 'aria_chat_last_sync' lesen → send
    chat_history_request {since}
  - Handler chat_history_response: incoming → ChatMessage[] mappen,
    Attachments aus 'files'-Array rekonstruieren, mergen (Dedup via timestamp),
    lastSync hochziehen
  - Handler chat_cleared: setMessages([]) + AsyncStorage 'chat_messages' +
    'last_sync' weg
  - Bei jeder eingehenden chat-Message: 'aria_chat_last_sync' updaten damit
    Reconnect nicht doppelt nachzieht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:24:52 +02:00
parent b6b4b1b4d9
commit 0ec4b00879
4 changed files with 154 additions and 4 deletions
+80
View File
@@ -919,6 +919,56 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
def _append_chat_backup(self, entry: dict) -> None:
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
Wird von Diagnostic + App als History-Quelle gelesen.
entry braucht mindestens {role, text}; ts wird ergaenzt."""
try:
line = {"ts": int(asyncio.get_event_loop().time() * 1000)}
line.update(entry)
Path("/shared/config").mkdir(parents=True, exist_ok=True)
with open("/shared/config/chat_backup.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("[backup] chat_backup-Write fehlgeschlagen: %s", e)
def _read_chat_backup_since(self, since_ms: int, limit: int = 100) -> list[dict]:
"""Liest chat_backup.jsonl, gibt Eintraege > since_ms zurueck, max limit neueste.
File-deleted-Marker werden honoriert: vor einem file_deleted-Marker liegende
Eintraege mit gleichem Pfad werden als deleted markiert."""
path = Path("/shared/config/chat_backup.jsonl")
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except Exception as e:
logger.warning("[backup] Lesen fehlgeschlagen: %s", e)
return []
out: list[dict] = []
for raw in lines:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
ts = obj.get("ts") or 0
if ts <= since_ms:
continue
# file_deleted-Marker: nicht als Chat ausliefern, aber an die App schicken
# damit sie ihre Bubbles updaten kann (separater Pfad existiert ja schon)
if obj.get("type") == "file_deleted":
continue
role = obj.get("role")
if role not in ("user", "assistant"):
continue
out.append(obj)
# Auf "limit" neueste cappen
if len(out) > limit:
out = out[-limit:]
return out
async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core.
@@ -933,6 +983,9 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
return
# Antwort in chat_backup.jsonl loggen (cleaned text, ohne File-Marker)
# — passiert weiter unten nach extract_file_markers
# File-Marker `[FILE: /shared/uploads/aria_xyz.pdf]` extrahieren —
# ARIA legt damit Dateien fuer den User bereit (Bilder, PDFs, etc.).
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
@@ -949,6 +1002,15 @@ class ARIABridge:
f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker)
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
self._append_chat_backup({
"role": "assistant",
"text": text,
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
})
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1184,6 +1246,10 @@ class ARIABridge:
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
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})
# agent_activity broadcasten (App + Diagnostic "ARIA denkt..." Indicator)
await self._send_to_rvs({
"type": "agent_activity",
@@ -1657,6 +1723,20 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "chat_history_request":
# App holt verpasste Nachrichten beim Reconnect.
# payload: {since: <ts_ms>}, default 0 = alles
since = int(payload.get("since") or 0)
limit = int(payload.get("limit") or 100)
logger.info("[rvs] chat_history_request since=%d limit=%d", since, limit)
messages = self._read_chat_backup_since(since, limit=limit)
await self._send_to_rvs({
"type": "chat_history_response",
"payload": {"messages": messages, "since": since},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
elif msg_type == "file_list_request":
# App fragt die Liste aller /shared/uploads/-Dateien an.
logger.info("[rvs] file_list_request von App")