diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index bf3928c..8d03e80 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -686,23 +686,26 @@ const ChatScreen: React.FC = () => { // gesetzt UND text leer/Placeholder) // - User-Bubbles deren clientMsgId der Server noch nicht kennt: // z.B. waehrend Reconnect-Race oder solange flushQueuedMessages - // noch laeuft. ABER: wenn der Server eine textgleiche Bubble - // im gleichen 5-Min-Fenster hat (Alter Backup-Eintrag ohne - // clientMsgId, vor dem Bridge-Patch geschrieben), werten wir - // das als Treffer und verwerfen die lokale Kopie — sonst - // Doppelpost: einmal als Server-Bubble (delivered) und einmal - // als lokale failed/queued mit Retry-Knopf. - const FIVE_MIN = 5 * 60 * 1000; + // noch laeuft. ABER: wenn der Server eine textgleiche User- + // Bubble hat (egal mit welcher cmid oder ohne — z.B. wenn die + // Bubble aus einer Bridge-Version vor dem clientMsgId-Patch + // stammt oder wenn die ts kaputt sind), werten wir das als + // Treffer und verwerfen die lokale Kopie. Sonst Doppelpost: + // einmal als Server-Bubble (delivered) und einmal als lokale + // failed/queued mit Retry-Knopf. + const serverUserTexts = new Set( + fromServer.filter(s => s.sender === 'user').map(s => s.text || '') + ); const localOnly = prev.filter(m => { if (m.skillCreated || m.triggerCreated || m.memorySaved) return true; if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true; if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) { - const serverHasIt = fromServer.some(s => - s.sender === 'user' && - s.text === m.text && - Math.abs((s.timestamp || 0) - (m.timestamp || 0)) < FIVE_MIN, - ); - if (serverHasIt) return false; + // Text-Match-Fallback: wenn der Server irgendwo eine textgleiche + // User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts + // kaputt etc.) — wir verwerfen die lokale Kopie. Leerer Text + // (z.B. nur Anhang) faellt nicht in den Vergleich. + const text = m.text || ''; + if (text && serverUserTexts.has(text)) return false; return true; } return false; diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 6a8a8ca..f4da5b5 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -997,8 +997,13 @@ class ARIABridge: """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. - Returns den ts (auch fuer Bubble-Loeschen-Tracking).""" - ts = int(asyncio.get_event_loop().time() * 1000) + Returns den ts (auch fuer Bubble-Loeschen-Tracking). + + WICHTIG: ts ist UNIX-ms (time.time()*1000), NICHT loop-time. + Loop-time ist Container-monotonic — bei jedem Restart wieder 0. + Das brach die App-History-Sortierung weil App-side Date.now() + (echtes UNIX-ms) mit Bridge-Container-Uptime gemischt wurde.""" + ts = int(time.time() * 1000) try: line = {"ts": ts} line.update(entry) diff --git a/tools/migrate_chat_backup_ts.py b/tools/migrate_chat_backup_ts.py new file mode 100644 index 0000000..8323004 --- /dev/null +++ b/tools/migrate_chat_backup_ts.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen. + +Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`, +was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side +`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History. + +Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime, +decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten +(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn). + +Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix) +werden unveraendert gelassen. + +Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts +schon UNIX-ms und werden nicht angefasst. + +Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace. +""" + +import json +import os +import shutil +import sys +import time +from pathlib import Path + +UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX +GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime + + +def migrate(path: Path) -> None: + if not path.exists(): + print(f"Datei nicht da: {path}") + sys.exit(1) + + raw = path.read_text(encoding="utf-8").splitlines() + entries = [] + for raw_line in raw: + s = raw_line.strip() + if not s: + continue + try: + entries.append(json.loads(s)) + except Exception as e: + print(f" ueberspringe kaputte Zeile: {e}") + continue + + if not entries: + print("Datei leer") + return + + file_mtime_ms = int(os.path.getmtime(path) * 1000) + n = len(entries) + fixed = 0 + + # Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer + # Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem + # UNIX-ms bleiben unangetastet. + for i, entry in enumerate(entries): + ts = entry.get("ts", 0) + if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD: + # Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime + new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000 + entry["ts"] = new_ts + fixed += 1 + + if fixed == 0: + print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)") + return + + # Backup + bak = path.with_suffix(path.suffix + ".bak") + shutil.copy2(path, bak) + print(f"Backup: {bak}") + + # Atomic rewrite + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + tmp.replace(path) + + print(f"Migration fertig: {fixed}/{n} ts umgeschrieben") + print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}") + print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}") + + +if __name__ == "__main__": + default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl") + path = Path(sys.argv[1]) if len(sys.argv) > 1 else default + migrate(path)