feat(chat): Muelltonne pro Bubble — gezielt eine Nachricht loeschen
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) <noreply@anthropic.com>
This commit is contained in:
+125
-4
@@ -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: <chat_backup-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.
|
||||
|
||||
Reference in New Issue
Block a user