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,
base64,
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 }),
...(location && { location }),
});
+49 -18
View File
@@ -790,7 +790,8 @@ class ARIABridge:
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text).
# 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
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
@@ -1839,16 +1840,16 @@ class ARIABridge:
return " ".join(parts) + " " + text
return text
def _build_pending_files_message(self, user_text: str) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
def _build_pending_files_message(self, user_text: str, files: list) -> str:
"""Baut eine Anweisung an aria-core aus den uebergebenen Files + optionalem
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
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 ""
kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts)
n = len(self._pending_files)
n = len(files)
anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text:
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.")
async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
aria-core senden ('warte auf Anweisung'-Variante)."""
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._pending_files:
return
text = self._build_pending_files_message("")
files = self._pending_files
text = self._build_pending_files_message("", files)
self._pending_files = []
self._pending_files_flush_task = None
pid = self._pending_files_project_id
@@ -1873,23 +1875,48 @@ class ARIABridge:
await self.send_to_core(text, source="app-file", project_id=pid)
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:
Files + Text zu einer einzigen aria-core-Nachricht mergen.
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
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext
zurueck, damit Anhaenge im richtigen Projekt landen statt im Hauptchat."""
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
"""
if not self._pending_files:
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():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
pid = (project_id or "").strip() or self._pending_files_project_id
self._pending_files_project_id = ""
if remaining:
self._pending_files_flush_task = asyncio.create_task(
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
)
else:
self._pending_files_project_id = ""
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
# fertig ist (siehe chat-handler oben).
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
@@ -2364,7 +2391,8 @@ class ARIABridge:
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls.
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:
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
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",
file_name, e)
# 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)))
# In Pending-Queue + Flush-Timer (anti-spam Buffering).
# 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():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = asyncio.create_task(