fix: Sprachnachricht-Bubble defensiv + Bild+Text als eine Anfrage
Bug 2: STT-Result schreibt jetzt eine neue User-Bubble wenn keine
Placeholder im State gefunden wird (statt das Update zu verwerfen).
Schuetzt vor Race-Conditions zwischen audio-send und State-Updates,
damit der gesprochene Text immer im Chat erscheint.
Bug 3: Bild + Text wurden als zwei getrennte Events ('file' + 'chat')
gesendet, jeder triggerte einen eigenen send_to_core. ARIA antwortete
zweimal — einmal "warte auf Anweisung" beim Bild, dann nochmal auf
den Text. Bridge buffert jetzt eingehende file-Events 800ms; kommt in
dem Fenster ein chat, werden alle Files + Text zu einer einzigen
aria-core-Nachricht gemerged. Kein chat → Files alleine wie bisher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8761d1a1b7
commit
20123de827
|
|
@ -281,9 +281,22 @@ const ChatScreen: React.FC = () => {
|
||||||
const idx = prev.findIndex(m =>
|
const idx = prev.findIndex(m =>
|
||||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
||||||
);
|
);
|
||||||
if (idx < 0) return prev;
|
const newText = `\uD83C\uDFA4 ${sttText}`;
|
||||||
|
if (idx < 0) {
|
||||||
|
// Defensiv: wenn keine Placeholder im State (z.B. weil sie nie
|
||||||
|
// hinzugefuegt wurde oder schon durch ein anderes Update verloren
|
||||||
|
// ging), die Sprachnachricht trotzdem als neue Bubble einfuegen.
|
||||||
|
// Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht.
|
||||||
|
return capMessages([...prev, {
|
||||||
|
id: nextId(),
|
||||||
|
sender: 'user',
|
||||||
|
text: newText,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||||
|
}]);
|
||||||
|
}
|
||||||
const next = prev.slice();
|
const next = prev.slice();
|
||||||
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
|
next[idx] = { ...next[idx], text: newText };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,15 @@ class ARIABridge:
|
||||||
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
|
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
|
||||||
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
|
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
|
||||||
self._remote_stt_ready: bool = False
|
self._remote_stt_ready: bool = False
|
||||||
|
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
|
||||||
|
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
|
||||||
|
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
|
||||||
|
# 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]] = []
|
||||||
|
self._pending_files_flush_task: Optional[asyncio.Task] = None
|
||||||
|
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
"""Initialisiert alle Komponenten.
|
"""Initialisiert alle Komponenten.
|
||||||
|
|
@ -1019,6 +1028,51 @@ class ARIABridge:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
|
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
|
||||||
|
|
||||||
|
def _build_pending_files_message(self, user_text: str) -> str:
|
||||||
|
"""Baut eine Anweisung an aria-core aus den gepufferten 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:
|
||||||
|
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)
|
||||||
|
anhang = "Anhang" if n == 1 else "Anhaenge"
|
||||||
|
if user_text:
|
||||||
|
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||||
|
f"Er sagt dazu: \"{user_text}\"")
|
||||||
|
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||||
|
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)."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
if not self._pending_files:
|
||||||
|
return
|
||||||
|
text = self._build_pending_files_message("")
|
||||||
|
self._pending_files = []
|
||||||
|
self._pending_files_flush_task = None
|
||||||
|
await self.send_to_core(text, source="app-file")
|
||||||
|
|
||||||
|
async def _flush_pending_files_with_text(self, user_text: 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)."""
|
||||||
|
if not self._pending_files:
|
||||||
|
return False
|
||||||
|
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 = []
|
||||||
|
await self.send_to_core(text, source="app-file+chat")
|
||||||
|
return True
|
||||||
|
|
||||||
async def send_to_core(self, text: str, source: str = "bridge") -> None:
|
async def send_to_core(self, text: str, source: str = "bridge") -> None:
|
||||||
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
|
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
|
||||||
if self.ws_core is None:
|
if self.ws_core is None:
|
||||||
|
|
@ -1181,6 +1235,13 @@ class ARIABridge:
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self._next_speed_override = None
|
self._next_speed_override = None
|
||||||
if text:
|
if text:
|
||||||
|
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
|
||||||
|
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||||
|
# zwei separater send_to_core-Calls.
|
||||||
|
merged = await self._flush_pending_files_with_text(text)
|
||||||
|
if merged:
|
||||||
|
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
|
||||||
|
else:
|
||||||
logger.info("[rvs] App-Chat: '%s'", text[:80])
|
logger.info("[rvs] App-Chat: '%s'", text[:80])
|
||||||
await self.send_to_core(text, source="app")
|
await self.send_to_core(text, source="app")
|
||||||
return
|
return
|
||||||
|
|
@ -1341,59 +1402,46 @@ class ARIABridge:
|
||||||
await self.ws_core.send(raw_message)
|
await self.ws_core.send(raw_message)
|
||||||
|
|
||||||
elif msg_type == "file":
|
elif msg_type == "file":
|
||||||
# Datei von der App → als Text-Nachricht an aria-core
|
# Datei von der App: speichern + zu Pending-Queue hinzufuegen.
|
||||||
|
# Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW)
|
||||||
|
# zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA
|
||||||
|
# zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat.
|
||||||
file_name = payload.get("name", "unbekannt")
|
file_name = payload.get("name", "unbekannt")
|
||||||
file_type = payload.get("type", "")
|
file_type = payload.get("type", "")
|
||||||
file_b64 = payload.get("base64", "")
|
file_b64 = payload.get("base64", "")
|
||||||
file_size = payload.get("size", 0)
|
|
||||||
width = payload.get("width", 0)
|
width = payload.get("width", 0)
|
||||||
height = payload.get("height", 0)
|
height = payload.get("height", 0)
|
||||||
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
|
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
|
||||||
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
|
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
|
||||||
|
|
||||||
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
|
|
||||||
SHARED_DIR = "/shared/uploads"
|
SHARED_DIR = "/shared/uploads"
|
||||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||||
|
|
||||||
if file_b64 and file_type.startswith("image/"):
|
if not file_b64:
|
||||||
# Bild in Shared Volume speichern
|
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
||||||
|
await self.send_to_core(text, source="app-file")
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_type.startswith("image/"):
|
||||||
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
|
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
|
||||||
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
||||||
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
|
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
|
||||||
with open(file_path, "wb") as f:
|
else:
|
||||||
f.write(base64.b64decode(file_b64))
|
|
||||||
size_kb = len(file_b64) // 1365
|
|
||||||
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
|
|
||||||
# ERST an aria-core senden (wichtigster Schritt)
|
|
||||||
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
|
|
||||||
f"{f' ({width}x{height}px)' if width else ''}"
|
|
||||||
f", {size_kb}KB."
|
|
||||||
f" Das Bild liegt unter: {file_path}"
|
|
||||||
f" Warte auf Stefans Anweisung was du damit tun sollst.")
|
|
||||||
await self.send_to_core(text, source="app-file")
|
|
||||||
# Dann App informieren (optional, darf nicht crashen)
|
|
||||||
try:
|
|
||||||
await self._send_to_rvs({
|
|
||||||
"type": "file_saved",
|
|
||||||
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
|
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
|
|
||||||
elif file_b64:
|
|
||||||
# Andere Datei in Shared Volume speichern
|
|
||||||
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
||||||
file_path = os.path.join(SHARED_DIR, safe_name)
|
file_path = os.path.join(SHARED_DIR, safe_name)
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
f.write(base64.b64decode(file_b64))
|
f.write(base64.b64decode(file_b64))
|
||||||
size_kb = len(file_b64) // 1365
|
size_kb = len(file_b64) // 1365
|
||||||
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
||||||
# ERST an aria-core senden
|
|
||||||
text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
|
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
||||||
f" ({file_type}, {size_kb}KB)."
|
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
||||||
f" Die Datei liegt unter: {file_path}"
|
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||||
f" Warte auf Stefans Anweisung was du damit tun sollst.")
|
self._pending_files_flush_task.cancel()
|
||||||
await self.send_to_core(text, source="app-file")
|
self._pending_files_flush_task = asyncio.create_task(
|
||||||
|
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "file_saved",
|
"type": "file_saved",
|
||||||
|
|
@ -1402,9 +1450,6 @@ class ARIABridge:
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
|
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
|
||||||
else:
|
|
||||||
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
|
||||||
await self.send_to_core(text, source="app-file")
|
|
||||||
|
|
||||||
elif msg_type == "file_request":
|
elif msg_type == "file_request":
|
||||||
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
|
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue