fix(files): Bilder korrekt zuordnen bei Queue (clientMsgId-Korrelation)

Die App schickt Datei (fire-and-forget) und Text (ACK-getrackt, bei Queue
verzoegert) als getrennte RVS-Nachrichten; die Datei trug keine clientMsgId.
Die Bridge mergte gepufferte Files rein per Timing an den naechsten Text →
bei mehreren schnellen Nachrichten landeten Bilder beim falschen Text.

App: file-Send traegt jetzt dieselbe clientMsgId wie der Text.
Bridge: puffert Files mit cmid und merged beim Text-Flush NUR die Files mit
passender cmid; Rest bleibt fuer seine eigene Nachricht gepuffert. Fallback
(Legacy/kein Treffer) = altes Verhalten, damit nie ein Bild verloren geht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:41:14 +02:00
co-authored by Claude Opus 4.8
parent 85d190e98c
commit 2ad8c2f245
2 changed files with 53 additions and 18 deletions
+4
View File
@@ -2371,6 +2371,10 @@ const ChatScreen: React.FC = () => {
size: file.size, size: file.size,
base64, base64,
projectId: activePid, projectId: activePid,
// Korrelation: dieselbe clientMsgId wie der Text, damit die Bridge
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
...(cmid && { clientMsgId: cmid }),
...(isPhoto && file.width && { width: file.width, height: file.height }), ...(isPhoto && file.width && { width: file.width, height: file.height }),
...(location && { location }), ...(location && { location }),
}); });
+48 -17
View File
@@ -790,7 +790,8 @@ class ARIABridge:
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf # Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text). # Anweisung" beim file, einmal auf den Chat-Text).
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height) # Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
self._pending_files: list[tuple[str, str, str, int, int, int]] = [] # (file_path, name, type, kb, width, height, clientMsgId)
self._pending_files: list[tuple[str, str, str, int, int, int, str]] = []
self._pending_files_flush_task: Optional[asyncio.Task] = None self._pending_files_flush_task: Optional[asyncio.Task] = None
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload). # Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen # Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
@@ -1839,16 +1840,16 @@ class ARIABridge:
return " ".join(parts) + " " + text return " ".join(parts) + " " + text
return text return text
def _build_pending_files_message(self, user_text: str) -> str: def _build_pending_files_message(self, user_text: str, files: list) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem """Baut eine Anweisung an aria-core aus den uebergebenen Files + optionalem
User-Text. user_text leer → 'warte auf Anweisung'-Variante.""" User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
parts: list[str] = [] parts: list[str] = []
for fp, name, ftype, kb, w, h in self._pending_files: for fp, name, ftype, kb, w, h, _cmid in files:
dim = f" {w}x{h}px" if (w and h) else "" dim = f" {w}x{h}px" if (w and h) else ""
kind = "Bild" if ftype.startswith("image/") else "Datei" kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}") parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts) files_summary = "\n".join(parts)
n = len(self._pending_files) n = len(files)
anhang = "Anhang" if n == 1 else "Anhaenge" anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text: if user_text:
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n" return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
@@ -1857,15 +1858,16 @@ class ARIABridge:
f"Warte auf seine Anweisung was du damit tun sollst.") f"Warte auf seine Anweisung was du damit tun sollst.")
async def _flush_pending_files_after(self, delay: float) -> None: async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an """Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
aria-core senden ('warte auf Anweisung'-Variante).""" Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
try: try:
await asyncio.sleep(delay) await asyncio.sleep(delay)
except asyncio.CancelledError: except asyncio.CancelledError:
return return
if not self._pending_files: if not self._pending_files:
return return
text = self._build_pending_files_message("") files = self._pending_files
text = self._build_pending_files_message("", files)
self._pending_files = [] self._pending_files = []
self._pending_files_flush_task = None self._pending_files_flush_task = None
pid = self._pending_files_project_id pid = self._pending_files_project_id
@@ -1873,22 +1875,47 @@ class ARIABridge:
await self.send_to_core(text, source="app-file", project_id=pid) await self.send_to_core(text, source="app-file", project_id=pid)
async def _flush_pending_files_with_text(self, user_text: str, async def _flush_pending_files_with_text(self, user_text: str,
project_id: str = "") -> bool: project_id: str = "",
client_msg_id: str = "") -> bool:
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind: """Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
Files + Text zu einer einzigen aria-core-Nachricht mergen. Files + Text zu einer einzigen aria-core-Nachricht mergen.
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden). Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
KORRELATION (Fix Queue-Bug): Files tragen dieselbe clientMsgId wie ihr
Text. Bei einer Queue gehen Files (fire-and-forget) und Text (ACK-
getrackt, ggf. verzoegert) auseinander — ohne Korrelation landeten die
Bilder beim falschen Text. Wir mergen darum NUR die Files mit passender
cmid; der Rest bleibt gepuffert fuer seine eigene Nachricht. Fallback
(Legacy-App ohne cmid an Files, oder cmid ohne Treffer): altes Verhalten
(alle Files mit diesem Text), damit nie ein Bild verloren geht.
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
zurueck, damit Anhaenge im richtigen Projekt landen statt im Hauptchat.""" """
if not self._pending_files: if not self._pending_files:
return False return False
cmid = (client_msg_id or "").strip()
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
if not matching:
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
matching = list(self._pending_files)
remaining: list = []
else:
remaining = [f for f in self._pending_files if f not in matching]
text = self._build_pending_files_message(user_text, matching)
self._pending_files = remaining
pid = (project_id or "").strip() or self._pending_files_project_id
# Flush-Timer neu setzen wenn noch Files anderer Nachrichten warten,
# sonst zuruecksetzen.
if self._pending_files_flush_task and not self._pending_files_flush_task.done(): if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel() self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text) if remaining:
self._pending_files = [] self._pending_files_flush_task = asyncio.create_task(
pid = (project_id or "").strip() or self._pending_files_project_id self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
)
else:
self._pending_files_project_id = "" self._pending_files_project_id = ""
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain # create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
# fertig ist (siehe chat-handler oben). # fertig ist (siehe chat-handler oben).
@@ -2364,7 +2391,8 @@ class ARIABridge:
# gesendet), mergen wir sie zu einer einzigen Anfrage statt # gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls. # zwei separater send_to_core-Calls.
merged = await self._flush_pending_files_with_text( merged = await self._flush_pending_files_with_text(
text, project_id=str(payload.get("projectId") or "")) text, project_id=str(payload.get("projectId") or ""),
client_msg_id=client_msg_id or "")
if merged: if merged:
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'", logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
str(payload.get("projectId") or "") or "(main)", text[:80]) str(payload.get("projectId") or "") or "(main)", text[:80])
@@ -2658,8 +2686,11 @@ class ARIABridge:
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s", logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
file_name, e) file_name, e)
# In Pending-Queue + Flush-Timer (anti-spam Buffering) # In Pending-Queue + Flush-Timer (anti-spam Buffering).
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0))) # clientMsgId mitpuffern → spaeterer Text-Flush ordnet die Datei
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
file_cmid = str(payload.get("clientMsgId") or "")
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
if self._pending_files_flush_task and not self._pending_files_flush_task.done(): if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel() self._pending_files_flush_task.cancel()
self._pending_files_flush_task = asyncio.create_task( self._pending_files_flush_task = asyncio.create_task(