From 3f2499b5288e15451cd73fa52daa942ce45f481b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 12 May 2026 16:42:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Muelltonne=20pro=20Bubble=20?= =?UTF-8?q?=E2=80=94=20gezielt=20eine=20Nachricht=20loeschen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan kann jetzt einzelne Chat-Bubbles loeschen (mit Rueckfrage). Die Bubble verschwindet aus chat_backup.jsonl (Bridge), Brain- Conversation (rolling window + jsonl) und allen Clients (App + Diagnostic). Genauso wichtig fuer ARIA: der gloeschte Turn ist im naechsten Chat-Prompt nicht mehr im Window. Pipeline: UI πŸ—‘ + confirm β†’ RVS delete_message_request {ts} β†’ Bridge._delete_chat_message: - chat_backup.jsonl Zeile mit ts entfernen (atomar via tmp+rename) - Brain POST /conversation/delete-turn (role+content match) - RVS broadcast chat_message_deleted {ts} β†’ App + Diagnostic entfernen Bubble lokal per ts-Match Backend-Aenderungen: - aria-brain/conversation.py: remove_by_match(role, content, ts_hint) + _rewrite_file (atomar). Match nahester Turn bei mehrfach gleichem content. - aria-brain/main.py: POST /conversation/delete-turn (POST statt DELETE weil FastAPI keine Bodys auf DELETE erlaubt) - bridge/aria_bridge.py: HTTP-Listener /internal/delete-chat-message + RVS-Handler delete_message_request. _append_chat_backup gibt jetzt ts zurueck, _process_core_response packt backupTs ins chat-Event. - rvs/server.js: ALLOWED_TYPES um delete_message_request + chat_message_deleted erweitert. - diagnostic/server.js: delete_chat_message-Action + chat_message_deleted Relay zum Browser. Frontend-Aenderungen: - diagnostic/index.html: πŸ—‘ erscheint on-hover in Bubbles mit data-ts, confirm()-Dialog, addChat + chat_history setzen data-ts. WS-Listener fuer chat_message_deleted entfernt Bubble per data-ts. - android/ChatScreen.tsx: backupTs in ChatMessage, Muelltonne-Button unten rechts in jeder Bubble, Alert-confirm, RVS-Listener fuer chat_message_deleted entfernt aus messages-State. Live-User-Bubbles (sofort gerendert vom eigenen Send) haben noch keinen backupTs bis der Bridge-Roundtrip durch ist β€” die Muelltonne erscheint dort erst nach kurzer Verzoegerung / Reload. Folgekommit kann das polieren wenn noetig. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 62 ++++++++++++++ aria-brain/conversation.py | 49 +++++++++++ aria-brain/main.py | 22 +++++ bridge/aria_bridge.py | 129 ++++++++++++++++++++++++++++- diagnostic/index.html | 55 +++++++++++- diagnostic/server.js | 17 ++++ rvs/server.js | 1 + 7 files changed, 327 insertions(+), 8 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 29acf9f..86c9d47 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -87,6 +87,11 @@ interface ChatMessage { fires_at?: string; condition?: string; }; + /** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge β€” Voraussetzung + * zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs + * sind noch nicht persistiert (kurzer Race) β€” Muelltonne erscheint erst + * wenn das chat_backup-Event vom Bridge zurueck kommt. */ + backupTs?: number; } // --- Konstanten --- @@ -415,6 +420,16 @@ const ChatScreen: React.FC = () => { return; } + // chat_message_deleted: Bridge hat eine Bubble aus chat_backup + Brain + // entfernt. Wir loeschen sie lokal per backupTs-Match. + if (message.type === 'chat_message_deleted') { + const ts = (message.payload || {}).ts; + if (typeof ts !== 'number') return; + console.log(`[Chat] chat_message_deleted ts=${ts}`); + setMessages(prev => prev.filter(m => m.backupTs !== ts)); + return; + } + // chat_history_response: kompletter Server-Stand. App ersetzt ihre // persistierte Chat-History damit. Lokal-only Bubbles (laufende // Voice-Aufnahmen ohne STT-Result, Skill-Created-Events ohne @@ -440,6 +455,7 @@ const ChatScreen: React.FC = () => { text: m.text || '', timestamp: m.ts || Date.now(), attachments: attachments.length ? attachments : undefined, + backupTs: typeof m.ts === 'number' ? m.ts : undefined, }; }); const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0); @@ -654,6 +670,7 @@ const ChatScreen: React.FC = () => { timestamp: ts, attachments: message.payload.attachments as Attachment[] | undefined, messageId: (message.payload.messageId as string) || undefined, + backupTs: (message.payload.backupTs as number) || undefined, }; return capMessages([...prev, ariaMsg]); }); @@ -1386,11 +1403,41 @@ const ChatScreen: React.FC = () => { {'\uD83D\uDD0A'} )} + {item.backupTs ? ( + confirmDeleteBubble(item)} + > + {'πŸ—‘'} + + ) : null} {time} ); }; + const confirmDeleteBubble = (item: ChatMessage) => { + const ts = item.backupTs; + if (!ts) return; + const preview = (item.text || '').slice(0, 80) || '(leere Bubble)'; + Alert.alert( + 'Bubble loeschen?', + `"${preview}${item.text && item.text.length > 80 ? '…' : ''}"\n\nWird aus chat_backup, Brain-Konversation und allen Clients entfernt.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', + style: 'destructive', + onPress: () => { + console.log(`[Chat] delete_message_request ts=${ts}`); + rvs.send('delete_message_request' as any, { ts }); + }, + }, + ], + ); + }; + const connectionDotColor = connectionState === 'connected' ? '#34C759' : connectionState === 'connecting' ? '#FFD60A' : '#FF3B30'; @@ -1967,6 +2014,21 @@ const styles = StyleSheet.create({ playButtonText: { fontSize: 16, }, + bubbleTrash: { + position: 'absolute', + top: 4, + right: 6, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255,59,48,0.18)', + alignItems: 'center', + justifyContent: 'center', + }, + bubbleTrashIcon: { + fontSize: 12, + color: '#FF6B6B', + }, fullscreenOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.95)', diff --git a/aria-brain/conversation.py b/aria-brain/conversation.py index e4d8861..a5668a2 100644 --- a/aria-brain/conversation.py +++ b/aria-brain/conversation.py @@ -121,6 +121,55 @@ class Conversation: self.turns = [] logger.warning("Konversation komplett zurueckgesetzt") + def _rewrite_file(self) -> None: + """Datei komplett aus In-Memory-State neu schreiben. + Wird nach Mutationen (Loeschen) genutzt. Alte distill-Marker + gehen dabei verloren β€” das ist OK weil der In-Memory-State + bereits post-distill ist.""" + try: + CONVERSATION_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp") + with tmp.open("w", encoding="utf-8") as f: + for t in self.turns: + f.write(json.dumps({ + "ts": t.ts, "role": t.role, + "content": t.content, "source": t.source, + }, ensure_ascii=False) + "\n") + tmp.replace(CONVERSATION_FILE) + except Exception as exc: + logger.warning("Konversation rewrite fehlgeschlagen: %s", exc) + + def remove_by_match(self, role: str, content: str, + ts_iso_hint: Optional[str] = None) -> bool: + """Entfernt EINEN Turn mit passendem role + content. + + Bei Mehrfach-Match (z.B. zwei identische 'ja'-Turns) waehlt + den naehesten zum ts_iso_hint, sonst den juengsten. + + Returns True wenn was entfernt wurde. + """ + candidates = [(i, t) for i, t in enumerate(self.turns) + if t.role == role and t.content == content] + if not candidates: + logger.info("[conv] remove_by_match: kein Match fuer role=%s content[:40]=%r", + role, content[:40]) + return False + if len(candidates) > 1 and ts_iso_hint: + def _diff(item): + _, turn = item + try: + return abs((datetime.fromisoformat(turn.ts.replace("Z", "+00:00")) + - datetime.fromisoformat(ts_iso_hint.replace("Z", "+00:00"))).total_seconds()) + except Exception: + return 1e9 + candidates.sort(key=_diff) + idx, turn = candidates[0] if not ts_iso_hint else candidates[0] + self.turns.pop(idx) + self._rewrite_file() + logger.info("[conv] Turn entfernt: role=%s ts=%s content[:40]=%r", + turn.role, turn.ts, turn.content[:40]) + return True + def stats(self) -> dict: return { "turns": len(self.turns), diff --git a/aria-brain/main.py b/aria-brain/main.py index c4bfd8c..e821b9c 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -432,6 +432,28 @@ def conversation_reset(): return {"ok": True, "turns": 0} +class ConvDeleteBody(BaseModel): + role: str + content: str + ts_iso_hint: Optional[str] = None + + +@app.post("/conversation/delete-turn") +def conversation_delete_turn(body: ConvDeleteBody): + """Entfernt einen einzelnen Turn aus dem Rolling-Window + jsonl. + Match per role + content (erstes Vorkommen wenn ts_iso_hint None, + sonst nahester zur Zeit). 404 wenn kein Match. + + POST statt DELETE weil FastAPI 0.115 keine Bodys auf DELETE + erlaubt β€” semantisch trotzdem eine Loeschung.""" + ok = conversation().remove_by_match( + role=body.role, content=body.content, ts_iso_hint=body.ts_iso_hint, + ) + if not ok: + raise HTTPException(404, "Turn mit diesem role+content nicht gefunden") + return {"ok": True, "turns": len(conversation().turns)} + + @app.post("/conversation/distill") def conversation_distill_now(): """Manueller Trigger fuer Destillat β€” fuer Tests oder vor einem diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 63f3b5f..a81c99a 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -958,18 +958,21 @@ class ARIABridge: Watcher: last_user_message_ago_sec basiert darauf.""" self._persist_state("activity", {"last_user_ts": int(time.time())}) - def _append_chat_backup(self, entry: dict) -> None: + def _append_chat_backup(self, entry: dict) -> int: """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.""" + entry braucht mindestens {role, text}; ts wird ergaenzt. + Returns den ts (auch fuer Bubble-Loeschen-Tracking).""" + ts = int(asyncio.get_event_loop().time() * 1000) try: - line = {"ts": int(asyncio.get_event_loop().time() * 1000)} + line = {"ts": ts} 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) + return ts 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. @@ -1043,7 +1046,7 @@ class ARIABridge: # 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({ + assistant_backup_ts = self._append_chat_backup({ "role": "assistant", "text": text, "files": [{"serverPath": f["serverPath"], "name": f["name"], @@ -1079,6 +1082,9 @@ class ARIABridge: "text": text, "sender": "aria", "messageId": message_id, + # backupTs = der ts in chat_backup.jsonl. Wird von Clients als + # Bubble-ID fuer das MΓΌlltonne-Loeschen verwendet (delete_message_request). + "backupTs": assistant_backup_ts, # Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional) "ttsText": tts_text_preview if tts_text_preview != text else "", }, @@ -1792,6 +1798,21 @@ class ARIABridge: }) return + elif msg_type == "delete_message_request": + # App oder Diagnostic loescht eine einzelne Bubble. + # payload: {ts: }. Bridge entfernt aus + # chat_backup.jsonl + Brain conversation.jsonl, broadcastet + # danach chat_message_deleted an alle Clients. + ts = payload.get("ts") + if not isinstance(ts, (int, float)): + logger.warning("[rvs] delete_message_request ohne valide ts: %r", payload) + return + logger.info("[rvs] delete_message_request ts=%s", ts) + result = await self._delete_chat_message(int(ts)) + if not result.get("ok"): + logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error")) + return + elif msg_type == "file_list_request": # App fragt die Liste aller /shared/uploads/-Dateien an. logger.info("[rvs] file_list_request von App") @@ -2455,6 +2476,21 @@ class ARIABridge: self._handle_trigger_fired(reply, trigger_name, ttype, events) ) await _send_response(writer, 200, {"ok": True}) + elif method == "POST" and path == "/internal/delete-chat-message": + try: + data = json.loads(body.decode("utf-8", "ignore")) + except Exception as exc: + await _send_response(writer, 400, {"error": f"bad json: {exc}"}) + return + ts = data.get("ts") + if not isinstance(ts, (int, float)): + await _send_response(writer, 400, {"error": "ts (number) erforderlich"}) + return + result = await self._delete_chat_message(int(ts)) + if result.get("ok"): + await _send_response(writer, 200, result) + else: + await _send_response(writer, 404, result) elif method == "GET" and path == "/health": await _send_response(writer, 200, {"ok": True, "service": "bridge-internal"}) else: @@ -2482,6 +2518,91 @@ class ARIABridge: except Exception: logger.exception("[bridge] Internal HTTP-Listener konnte nicht starten") + async def _delete_chat_message(self, ts: int) -> dict: + """Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation, + broadcastet chat_message_deleted via RVS. + Returns {ok, role, content_preview} oder {ok:False, error}. + """ + path = Path("/shared/config/chat_backup.jsonl") + if not path.exists(): + return {"ok": False, "error": "chat_backup.jsonl existiert nicht"} + + try: + lines = path.read_text(encoding="utf-8").splitlines() + except Exception as exc: + return {"ok": False, "error": f"Lesen fehlgeschlagen: {exc}"} + + kept: list[str] = [] + removed_entry: Optional[dict] = None + for raw in lines: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except Exception: + kept.append(raw) + continue + if obj.get("ts") == ts and removed_entry is None: + removed_entry = obj + continue + kept.append(raw) + + if removed_entry is None: + return {"ok": False, "error": f"Kein Eintrag mit ts={ts} gefunden"} + + # chat_backup.jsonl neu schreiben (atomar via tmp) + try: + tmp = path.with_suffix(".jsonl.tmp") + tmp.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8") + tmp.replace(path) + except Exception as exc: + return {"ok": False, "error": f"Schreiben fehlgeschlagen: {exc}"} + + role = removed_entry.get("role", "") + content = removed_entry.get("text", "") + logger.info("[chat-del] chat_backup ts=%s role=%s content[:40]=%r entfernt", + ts, role, content[:40]) + + # Brain conversation.jsonl auch entrΓΌmpeln (best-effort). + # ts in chat_backup ist asyncio-loop-time-ms, im Brain ist's eine ISO-UTC-Time. + # Die kann man nicht direkt mappen β€” wir uebergeben nur role+content + # und hoffen dass das eindeutig matched. Bei mehrfach gleichem content + # entfernt remove_by_match den juengsten passenden Turn. + if role in ("user", "assistant") and content: + try: + brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") + payload = json.dumps({"role": role, "content": content}).encode("utf-8") + def _post(): + req = urllib.request.Request( + f"{brain_url}/conversation/delete-turn", + data=payload, method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + except Exception: + return None + status = await asyncio.get_event_loop().run_in_executor(None, _post) + logger.info("[chat-del] Brain conversation/delete-turn β†’ %s", status) + except Exception as exc: + logger.warning("[chat-del] Brain-Call fehlgeschlagen: %s", exc) + + # RVS-Broadcast damit alle Clients die Bubble entfernen + try: + await self._send_to_rvs({ + "type": "chat_message_deleted", + "payload": {"ts": ts, "role": role}, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + except Exception as exc: + logger.warning("[chat-del] RVS-Broadcast fehlgeschlagen: %s", exc) + + return {"ok": True, "role": role, "content_preview": content[:80]} + async def _handle_trigger_fired(self, reply: str, trigger_name: str, ttype: str, events: list) -> None: """Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort. diff --git a/diagnostic/index.html b/diagnostic/index.html index 4d1705b..8b7a87e 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -67,7 +67,13 @@ padding: 12px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; } .chat-msg { padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5; word-wrap: break-word; max-width: 80%; white-space: pre-wrap; - box-shadow: 0 1px 2px rgba(0,0,0,0.4); } + box-shadow: 0 1px 2px rgba(0,0,0,0.4); position: relative; } + .chat-msg .bubble-trash { position:absolute; top:4px; right:6px; background:rgba(255,59,48,0.15); + color:#FF6B6B; border:none; border-radius:50%; width:22px; height:22px; + font-size:12px; line-height:18px; padding:0; cursor:pointer; opacity:0; + transition:opacity 0.15s; } + .chat-msg:hover .bubble-trash { opacity: 1; } + .chat-msg .bubble-trash:hover { background:#FF3B30; color:#fff; } .chat-msg.sent { background: #0096FF; color: #fff; align-self: flex-end; border-bottom-right-radius: 4px; } .chat-msg.received { background: #1E1E2E; color: #E8E8F0; align-self: flex-start; @@ -1378,7 +1384,23 @@ chatType = 'sent'; label = `via RVS (${sender})`; } - addChat(chatType, p.text || '?', label, { location: p.location }); + addChat(chatType, p.text || '?', label, { + location: p.location, + ttsText: p.ttsText, + backupTs: p.backupTs, + }); + return; + } + if (msg.type === 'chat_message_deleted') { + // Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt. + // Bubble lokal entfernen (data-ts-Match in beiden Chat-Boxen). + const ts = msg.payload?.ts; + if (!ts) return; + for (const box of [chatBox, document.getElementById('chat-box-fs')]) { + if (!box) continue; + const el = box.querySelector(`.chat-msg[data-ts="${ts}"]`); + if (el) el.remove(); + } return; } if (msg.type === 'proxy_result') { @@ -1453,6 +1475,7 @@ } const el = document.createElement('div'); el.className = `chat-msg ${m.type}`; + if (m.ts) el.dataset.ts = String(m.ts); // [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat) const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim(); const escaped = escapeHtml(cleaned); @@ -1463,7 +1486,10 @@ return `${match}`; }); const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?'; - el.innerHTML = `${linked}
${escapeHtml(m.meta)} β€” ${time}
`; + const trashBtn = m.ts + ? `` + : ''; + el.innerHTML = `${trashBtn}${linked}
${escapeHtml(m.meta)} β€” ${time}
`; chatBox.appendChild(el); } chatBox.scrollTop = chatBox.scrollHeight; @@ -1492,6 +1518,22 @@ } } + /** Loescht eine einzelne Chat-Bubble (mit Rueckfrage). + * Backend (Bridge) raeumt chat_backup.jsonl + Brain-Conversation + * und broadcastet danach chat_message_deleted β€” wir entfernen die + * Bubble lokal erst dann, nicht optimistisch. */ + function deleteDiagBubble(ts) { + if (!ts) return; + let preview = ''; + for (const box of [chatBox, document.getElementById('chat-box-fs')]) { + if (!box) continue; + const el = box.querySelector(`.chat-msg[data-ts="${ts}"]`); + if (el) { preview = (el.textContent || '').slice(0, 80); break; } + } + if (!confirm(`Diese Bubble wirklich loeschen?\n\n"${preview}…"\n\nWird aus chat_backup, Brain-Konversation und allen Clients entfernt.`)) return; + send({ action: 'delete_chat_message', ts }); + } + function sendDiagAttachments() { // Alle pending Dateien an RVS senden for (const f of diagPendingFiles) { @@ -1781,7 +1823,11 @@ gpsBlock = `
πŸ“ GPS: ${lat}, ${lon}
`; } } - const html = `${linked}${ttsBlock}${gpsBlock}
${escapeHtml(meta)} β€” ${new Date().toLocaleTimeString('de-DE')}
`; + const backupTs = options && options.backupTs; + const trashBtn = backupTs + ? `` + : ''; + const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}
${escapeHtml(meta)} β€” ${new Date().toLocaleTimeString('de-DE')}
`; // Thinking-Indikator ausblenden bei neuer Nachricht updateThinkingIndicator({ activity: 'idle' }); @@ -1791,6 +1837,7 @@ if (!box) continue; const el = document.createElement('div'); el.className = `chat-msg ${type}`; + if (backupTs) el.dataset.ts = String(backupTs); el.innerHTML = html; box.appendChild(el); box.scrollTop = box.scrollHeight; diff --git a/diagnostic/server.js b/diagnostic/server.js index 8085059..afbcc51 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -617,6 +617,12 @@ function connectRVS(forcePlain) { // Mode-Broadcast von der Bridge β†’ an Browser-Clients weiterreichen log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`); broadcast({ type: "mode", payload: msg.payload }); + } else if (msg.type === "chat_message_deleted") { + // Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt. + // An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen. + const ts = msg.payload?.ts; + log("info", "rvs", `chat_message_deleted ts=${ts}`); + broadcast({ type: "chat_message_deleted", payload: msg.payload }); } else if (msg.type === "voice_ready") { // XTTS-Bridge meldet Stimme fertig geladen β†’ an Browser durchreichen const v = msg.payload?.voice || ""; @@ -1835,6 +1841,17 @@ wss.on("connection", (ws) => { // Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() }); log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`); + } else if (msg.action === "delete_chat_message") { + // Bubble loeschen β€” Bridge raeumt chat_backup.jsonl + Brain-conversation + // + broadcastet chat_message_deleted via RVS. + const ts = Number(msg.ts); + if (!Number.isFinite(ts)) { + ws.send(JSON.stringify({ type: "log", level: "error", source: "server", + message: `delete_chat_message: ungueltiges ts=${msg.ts}` })); + return; + } + sendToRVS_raw({ type: "delete_message_request", payload: { ts }, timestamp: Date.now() }); + log("info", "server", `delete_message_request ts=${ts} an Bridge gesendet`); } else if (msg.action === "set_mode") { // Mode-Wechsel β†’ Bridge bearbeitet und broadcastet an alle Clients sendToRVS_raw({ type: "mode", payload: { mode: msg.mode }, timestamp: Date.now() }); diff --git a/rvs/server.js b/rvs/server.js index 36b059e..209bbb6 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -28,6 +28,7 @@ const ALLOWED_TYPES = new Set([ "trigger_created", "location_update", "location_tracking", "chat_history_request", "chat_history_response", "chat_cleared", + "delete_message_request", "chat_message_deleted", "file_delete_batch_request", "file_delete_batch_response", "file_zip_request", "file_zip_response", "xtts_delete_voice",