diff --git a/README.md b/README.md index d2e4dc9..941790b 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,9 @@ Erreichbar unter `http://:3001`. Teilt das Netzwerk mit der Bridge. - **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) -- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live +- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport) +- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck +- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry - **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat - **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr) - **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 14fccde..5c0a444 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -270,6 +270,9 @@ const ChatScreen: React.FC = () => { const flatListRef = useRef(null); const messageIdCounter = useRef(0); + // Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck- + // Retry) brauchen Zugriff auf den aktuellen Status einer Bubble. + const messagesRef = useRef([]); // Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit // nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates // vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung @@ -334,8 +337,19 @@ const ChatScreen: React.FC = () => { // - Wenn offline → status='queued', wird beim Reconnect rausgeschickt. // - Wenn online → status='sending', Timer fuer ACK-Erwartung. // - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'. + // - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet + // bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr. const dispatchWithAck = useCallback( (cmid: string, type: 'chat' | 'audio', payload: Record, attempt = 1) => { + // Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen + // (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA + // schon laengst geantwortet hat). + const current = messagesRef.current.find(m => m.clientMsgId === cmid); + if (current?.deliveryStatus === 'delivered') { + clearAckTimer(cmid); + pendingPayloads.current.delete(cmid); + return; + } pendingPayloads.current.set(cmid, { type, payload }); const online = connectionStateRef.current === 'connected'; if (!online) { @@ -350,6 +364,13 @@ const ChatScreen: React.FC = () => { cmid, setTimeout(() => { ackTimers.current.delete(cmid); + // Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen + // delivered wurde — sonst spawnen wir endlose Retries. + const fresh = messagesRef.current.find(m => m.clientMsgId === cmid); + if (fresh?.deliveryStatus === 'delivered') { + pendingPayloads.current.delete(cmid); + return; + } if (attempt >= MAX_SEND_ATTEMPTS) { updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt }); console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid); @@ -644,15 +665,27 @@ 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. 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.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) - ); + // 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; + 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; + return true; + } + return false; + }); // Server-Stand + lokal-only (chronologisch sortiert) const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp); return capMessages(merged); @@ -919,6 +952,14 @@ const ChatScreen: React.FC = () => { }); // ARIA hat geantwortet → Watchdog clearen, falls noch armiert clearStuckWatchdog(); + // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages + // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn + // ein ACK aus Netzgruenden verloren ging, soll der Retry nicht + // nachtraeglich loslaufen und die Bubble auf 'failed' setzen. + for (const cmid of Array.from(ackTimers.current.keys())) { + clearAckTimer(cmid); + pendingPayloads.current.delete(cmid); + } } // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable @@ -1225,6 +1266,10 @@ const ChatScreen: React.FC = () => { return () => { if (saveTimer.current) clearTimeout(saveTimer.current); }; }, [messages]); + // messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen + // damit Retries auf den aktuellen deliveryStatus reagieren koennen. + useEffect(() => { messagesRef.current = messages; }, [messages]); + // Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig // Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat // NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt. diff --git a/issue.md b/issue.md index 9ca3d02..ba8af11 100644 --- a/issue.md +++ b/issue.md @@ -341,10 +341,19 @@ Skills mit Tool-Use. - [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab - [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%. +### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake + +- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep +- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`) +- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt +- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart +- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry +- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt +- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart + ## Offen ### App Features -- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild) ### Architektur