fix(chat): Doppel-Bubble nach Retry + verwaiste ACK-Timer + docs
Race nach Etappe-3-Reconnect-Fix: lokale failed-Bubble (mit clientMsgId) und Server-Backup-Eintrag (ohne clientMsgId, aus alter Bridge-Version) landeten beide im Merge → User sah Doppelpost: einmal ueber der ARIA-Antwort (Server), einmal mit Retry-Knopf darunter (lokal). Plus ACK-Timer konnte weiterlaufen obwohl die Bubble schon delivered war — Retry pushte den Status zurueck auf sending und nach 30 s auf failed. App: - chat_history_response-Merge faellt zusaetzlich auf text+timestamp- Heuristik im 5-Min-Fenster zurueck wenn die Server-Bubble keine clientMsgId hat → lokale Kopie wird verworfen, kein Doppelpost - messagesRef + dispatchWithAck prueft vor Send/Retry ob die Bubble bereits delivered ist → kein verspaetetes failed mehr - ARIA-Reply cleart ALLE laufenden ACK-Timer (Bridge hat unsere Messages ja offensichtlich verarbeitet) Docs: - issue.md: neuer Block 'Chat-Stabilitaet' mit den drei Etappen + beiden Race-Fixes; AsyncStorage-Race-Punkt aus 'Offen' abgehakt - README.md: Chat-Such-Zeile aktualisiert (highlight statt filter), Jump-to-Bottom + Delivery-Status-Bubbles dokumentiert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -362,7 +362,9 @@ Erreichbar unter `http://<VM-IP>: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
|
||||
|
||||
@@ -270,6 +270,9 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
const flatListRef = useRef<FlatList>(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<ChatMessage[]>([]);
|
||||
// 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<string, unknown>, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user