diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 108893d..43ce267 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -217,6 +217,13 @@ function formatDur(sec: number): string { // TAP β€” was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch // weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche // Meldung statt eines Absturzes. +/** Signatur von sendPendingAttachments β€” fuer den Ref weiter unten. */ +type SendAttachmentsFn = ( + messageText: string, + explicitAttachments?: { file: any; isPhoto: boolean }[], + voiceMode?: { audioRequestId: string; clientMsgId: string }, +) => Promise; + const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024; const TAP_DL_MAX_BYTES = 64 * 1024 * 1024; @@ -391,6 +398,16 @@ const ChatScreen: React.FC = () => { // Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit const [serviceStatus, setServiceStatus] = useState>({}); const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false); + // Zwischenruf per Sprache: der πŸ“£-Knopf ohne Text "spannt" die naechste + // Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref + // wird gelesen, wenn die Aufnahme startet β€” der State nur fuer die Optik. + const [interjectArmed, setInterjectArmed] = useState(false); + const interjectArmedRef = useRef(false); + // sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter + // oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone + // greifen (TS2448 β€” siehe Kommentar an sendTextMessage). Umsortieren waere + // riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen. + const sendPendingAttachmentsRef = useRef(null); // GerΓ€telokale TTS-Config: globaler Toggle (aus Settings) + temporΓ€res Muten (Mund-Button) const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true); const [ttsMuted, setTtsMuted] = useState(false); @@ -1324,7 +1341,11 @@ const ChatScreen: React.FC = () => { return; } setMessages(prev => { - const newText = `\uD83C\uDFA4 ${sttText}`; + // Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014 + // sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in + // den laufenden Turn gegangen ist und keine eigene Antwort bekommt. + const isInterject = !!(message.payload as any).interject; + const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`; // Primaer: matche per audioRequestId (eindeutig pro Aufnahme). // So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander // gesendet wurden und ihre STT-Results ueberlappen. @@ -1339,7 +1360,9 @@ const ChatScreen: React.FC = () => { // Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring, // nimmt die ERSTE noch unaufgeloeste Placeholder. const idx = prev.findIndex(m => - m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') + m.sender === 'user' + && (m.text.includes('Spracheingabe wird verarbeitet') + || m.text.includes('Zwischenruf wird verarbeitet')) ); if (idx >= 0) { const next = prev.slice(); @@ -2413,11 +2436,18 @@ const ChatScreen: React.FC = () => { // produziert das Brain weiter TTS, die ins offene Mikro laeuft β†’ genau der // "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes // Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (πŸ“£). - const interruptAriaIfBusy = useCallback(() => { + // forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen + // werden β€” das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst + // redet ARIA in das offene Mikro hinein. + const interruptAriaIfBusy = useCallback((forInterject = false) => { if (audioService.isPlayingAudio()) { - audioService.haltAllPlayback('user startet Aufnahme β€” Interrupt'); - rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' }); - return true; + audioService.haltAllPlayback(forInterject + ? 'user ruft dazwischen β€” nur Wiedergabe stoppen' + : 'user startet Aufnahme β€” Interrupt'); + if (!forInterject) { + rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' }); + } + return !forInterject; } return false; }, []); @@ -2430,18 +2460,35 @@ const ChatScreen: React.FC = () => { const handleVoiceButtonStart = useCallback(async (): Promise => { const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; rememberMyRequest(audioRequestId); - const wasInterrupted = interruptAriaIfBusy(); + const asInterject = interjectArmedRef.current; + const wasInterrupted = interruptAriaIfBusy(asInterject); const location = await getCurrentLocation(); - const userMsg: ChatMessage = { - id: nextId(), - sender: 'user', - text: 'πŸŽ™ Spracheingabe wird verarbeitet...', - timestamp: Date.now(), - attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], - audioRequestId, - }; - setMessages(prev => capMessages([...prev, userMsg])); + // Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen + // (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der + // Satz dazu noch kommt. Die Bubble mit den Bildern macht + // sendPendingAttachments β€” sie traegt die audioRequestId und bekommt den + // gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble. + let attachmentCmid = ''; + if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) { + attachmentCmid = nextClientMsgId(); + const forVoice = pendingAttachments; + setPendingAttachments([]); + await sendPendingAttachmentsRef.current('', forVoice, { + audioRequestId, clientMsgId: attachmentCmid, + }); + } else { + const userMsg: ChatMessage = { + id: nextId(), + sender: 'user', + text: asInterject ? 'πŸ“£ Zwischenruf wird verarbeitet...' + : 'πŸŽ™ Spracheingabe wird verarbeitet...', + timestamp: Date.now(), + attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], + audioRequestId, + }; + setMessages(prev => capMessages([...prev, userMsg])); + } const { ok } = await audioService.startStreamingRecording({ audioRequestId, @@ -2456,15 +2503,37 @@ const ChatScreen: React.FC = () => { endpointMs: await loadSttEndpointMs(), hardCapMs: await loadMaxRecordingMs(), projectId: focusedProjectIdRef.current, + ...(attachmentCmid && { clientMsgId: attachmentCmid }), + ...(asInterject && { interject: true }), }); if (!ok) { + if (attachmentCmid) { + // Die Anhaenge sind SCHON hochgeladen β€” die Bubble darf nicht weg, + // sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem + // Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den + // Auftrag einfach tippen. + setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId + ? { ...m, text: 'πŸ“Ž Anhang gesendet β€” Mikro war belegt, bitte tippen', + audioRequestId: undefined } + : m)); + if (Platform.OS === 'android') { + ToastAndroid.show('Mikro nicht verfΓΌgbar β€” Anhang ist aber da', ToastAndroid.LONG); + } + return false; + } // Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) β€” Bubble weg. setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId)); return false; } + // Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme. + if (asInterject) { + interjectArmedRef.current = false; + setInterjectArmed(false); + } scheduleStaleAudioCleanup(audioRequestId, 60000); return true; - }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]); + }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup, + pendingAttachments]); // ARIA schliesst die Aufnahme SELBST β€” das programmatische Gegenstueck zum // Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der @@ -2570,6 +2639,11 @@ const ChatScreen: React.FC = () => { // Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch // nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste. explicitAttachments?: { file: any; isPhoto: boolean }[], + // Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann + // gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden), + // aber ohne Text β€” den liefert spaeter der stt_endpoint, und die Bridge + // merged beides ueber die clientMsgId zu einem Turn. + voiceMode?: { audioRequestId: string; clientMsgId: string }, ) => { const atts = explicitAttachments ?? pendingAttachments; if (atts.length === 0) return; @@ -2597,16 +2671,23 @@ const ChatScreen: React.FC = () => { // Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei // ist β€” files selber haben (noch) kein ACK-Tracking auf der Bridge. - const cmid = messageText ? nextClientMsgId() : undefined; + // Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am + // stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt. + const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined); const activePid = focusedProjectIdRef.current; const userMsg: ChatMessage = { id: msgId, sender: 'user', - text: messageText || `${atts.length} Anhang/Anhaenge`, + // Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text β€” + // via audioRequestId-Match, genau wie eine normale Sprachnachricht. So + // stehen Bild und Satz zusammen in einer Bubble statt in zwei. + text: voiceMode ? 'πŸŽ™ Spracheingabe wird verarbeitet...' + : (messageText || `${atts.length} Anhang/Anhaenge`), timestamp: Date.now(), attachments, projectId: activePid, - ...(cmid && { + ...(voiceMode && { audioRequestId: voiceMode.audioRequestId }), + ...(cmid && !voiceMode && { clientMsgId: cmid, deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', sendAttempts: 1, @@ -2644,6 +2725,9 @@ const ChatScreen: React.FC = () => { // die Datei genau DIESER Nachricht zuordnet β€” auch wenn Files (fire- // and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen. ...(cmid && { clientMsgId: cmid }), + // awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans + // Brain schicken, sondern auf die Sprachnachricht warten. + ...(voiceMode && { awaitVoice: true }), ...(isPhoto && file.width && { width: file.width, height: file.height }), ...(location && { location }), }); @@ -2651,8 +2735,8 @@ const ChatScreen: React.FC = () => { // Text als separate Nachricht (damit ARIA weiss was zu tun ist) β€” mit // dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble - // adressieren. - if (messageText && cmid) { + // adressieren. Im Sprach-Modus gibt es hier noch keinen Text. + if (messageText && cmid && !voiceMode) { dispatchWithAck(cmid, 'chat', { text: messageText, voice: localXttsVoiceRef.current, @@ -2665,6 +2749,9 @@ const ChatScreen: React.FC = () => { setPendingAttachments([]); setInputText(''); }, [pendingAttachments, getCurrentLocation, dispatchWithAck]); + // Ref aktuell halten β€” handleVoiceButtonStart greift die Funktion von hier + // (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef). + sendPendingAttachmentsRef.current = sendPendingAttachments; // Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und // sendPendingAttachments β€” beide stehen in seinem deps-Array, und wenn es @@ -3529,7 +3616,9 @@ const ChatScreen: React.FC = () => { style={styles.textInput} value={inputText} onChangeText={setInputText} - placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."} + placeholder={pendingAttachments.length > 0 + ? "Text zu den Anhaengen β€” oder einsprechen 🎀" + : "Nachricht an ARIA..."} placeholderTextColor="#555570" multiline maxLength={4000} @@ -3537,29 +3626,52 @@ const ChatScreen: React.FC = () => { returnKeyType="send" /> - {/* Senden oder Sprache */} + {/* Rechte Knopfreihe. + Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text. + Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein + Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen + und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache. + Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach + Lage, damit die Zeile nicht ueberlaeuft. */} + + {/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder + noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher. + Ohne Text: die naechste Aufnahme geht als Zwischenruf in den + laufenden Turn, statt sich als neue Anfrage anzustellen. */} + {interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? ( + { + if (inputText.trim()) { sendInterject(); return; } + const next = !interjectArmedRef.current; + interjectArmedRef.current = next; + setInterjectArmed(next); + if (Platform.OS === 'android') { + ToastAndroid.show( + next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus' + : 'Zwischenruf abgew\u00E4hlt', + ToastAndroid.SHORT); + } + }} + accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'} + > + {'\uD83D\uDCE3'} + + ) : null} + + + {inputText.trim() || pendingAttachments.length > 0 ? ( - <> - {/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und - Text da ist. Schiebt die Korrektur in den laufenden Turn statt - sie anzustellen. */} - {inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? ( - - {'\uD83D\uDCE3'} - - ) : null} - - {'\u2B06\uFE0F'} - - + + {'\u2B06\uFE0F'} + ) : ( <> - {/* Mund-Button: TTS auf diesem Geraet muten/aufheben. Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */} {ttsDeviceEnabled && ( @@ -4085,6 +4197,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginRight: 6, }, + // Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller + // plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach. + interjectButtonArmed: { + backgroundColor: '#FFB340', + borderWidth: 2, + borderColor: '#FFF', + }, interjectIcon: { fontSize: 18, }, diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index b399b6f..814a2e6 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -1141,6 +1141,13 @@ class AudioService { * Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit * automatisch in dem Kontext den Stefan gerade sieht. */ projectId?: string; + /** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung. + * Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn + * zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */ + clientMsgId?: string; + /** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue + * Anfrage in die Warteschlange (Sprach-Gegenstueck zum πŸ“£-Knopf). */ + interject?: boolean; }): Promise<{ requestId: string; ok: boolean; error?: string }> { if (this.recordingState !== 'idle') { console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState); @@ -1245,6 +1252,8 @@ class AudioService { sampleRate: 16000, projectId: opts.projectId || '', targetInstance: this.streamTargetInstance, + ...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}), + ...(opts.interject ? { interject: true } : {}), }); const started = await PcmStreamRecorder.start(); diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index f8a8f9a..e650c6b 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -739,6 +739,14 @@ class ARIABridge: # weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen, # nutzen wir das als Default (Voice folgt dem sichtbaren Kontext). self._stt_stream_projects: dict[str, str] = {} + # Zusatz-Absicht pro Stream, auch von stt_stream_start: + # cmid β€” clientMsgId der Anhaenge, die zu DIESER Aufnahme gehoeren + # (Sprachnachricht als Beschreibung zu einem Bild/Video). + # interject β€” die Aufnahme ist ein Zwischenruf in den laufenden Turn, + # keine neue Anfrage. + # Getrennt von _stt_stream_projects gehalten: der Fokus-Default greift + # auch fuer alte App-Versionen, die diese Felder nicht mitschicken. + self._stt_stream_meta: dict[str, dict] = {} # Voice-Override aus letzter Chat-Nachricht einer App. # Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt. # So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request). @@ -808,6 +816,18 @@ class ARIABridge: # Projekt landen statt im Hauptchat. self._pending_files_project_id: str = "" self._PENDING_FILES_WINDOW_SEC: float = 0.8 + # Wartet ein Upload auf eine SPRACHNACHRICHT als Beschreibung, reichen + # 0.8s nicht: der Nutzer spricht erst, und Whisper braucht danach noch + # ein paar Sekunden fuers Final-Transkript. Mit dem kurzen Fenster waere + # der Anhang laengst alleine rausgegangen ("warte auf seine Anweisung"), + # und der gesprochene Text kaeme als zweiter, unverbundener Turn. + # Das lange Fenster ist nur eine Notbremse gegen Haengenbleiben, falls + # gar kein Endpoint mehr kommt β€” der Normalfall ist der Merge im + # stt_endpoint-Handler. Es MUSS ueber dem Aufnahme-Hard-Cap der App + # liegen (Default 5 Minuten) plus Whisper-Final, sonst waeren die + # Anhaenge bei einer langen Aufnahme vorher alleine rausgegangen. + self._PENDING_FILES_VOICE_WINDOW_SEC: float = 420.0 + self._pending_files_await_voice: bool = False def initialize(self) -> None: """Initialisiert alle Komponenten. @@ -1879,6 +1899,13 @@ class ARIABridge: return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n" f"Warte auf seine Anweisung was du damit tun sollst.") + def _pending_files_window(self) -> float: + """Wie lange auf den zugehoerigen Text gewartet wird, bevor die Anhaenge + alleine rausgehen. Lang, wenn eine Sprachnachricht unterwegs ist.""" + return (self._PENDING_FILES_VOICE_WINDOW_SEC + if self._pending_files_await_voice + else self._PENDING_FILES_WINDOW_SEC) + async def _flush_pending_files_after(self, delay: float) -> None: """Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten Files alleine an aria-core senden ('warte auf Anweisung'-Variante).""" @@ -1892,13 +1919,15 @@ class ARIABridge: text = self._build_pending_files_message("", files) self._pending_files = [] self._pending_files_flush_task = None + self._pending_files_await_voice = False pid = self._pending_files_project_id self._pending_files_project_id = "" 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 = "", - client_msg_id: str = "") -> bool: + client_msg_id: str = "", + require_match: bool = False) -> 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). @@ -1913,12 +1942,20 @@ class ARIABridge: project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck. + + require_match: nur mergen, wenn die cmid wirklich passt. Der Sprach-Pfad + nutzt das β€” sonst wuerde eine Sprachnachricht, die zufaellig waehrend + eines fremden Uploads fertig wird, dessen Anhaenge an sich reissen. Beim + Text-Pfad bleibt der Fallback (alles mergen) wie bisher, damit nie ein + Bild verloren geht. """ 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: + if require_match: + return False # Kein cmid-Treffer β†’ altes Verhalten: alle gepufferten Files mergen. matching = list(self._pending_files) remaining: list = [] @@ -1935,10 +1972,11 @@ class ARIABridge: self._pending_files_flush_task = None if remaining: self._pending_files_flush_task = asyncio.create_task( - self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC) + self._flush_pending_files_after(self._pending_files_window()) ) else: self._pending_files_project_id = "" + self._pending_files_await_voice = False # 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)) @@ -2752,10 +2790,17 @@ class ARIABridge: # 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)) + # awaitVoice: der Nutzer spricht gerade die Beschreibung zu diesem + # Anhang ein. Dann NICHT nach 0.8s alleine rausschicken, sondern auf + # den stt_endpoint warten (siehe _PENDING_FILES_VOICE_WINDOW_SEC). + if payload.get("awaitVoice"): + self._pending_files_await_voice = True + logger.info("[rvs] Anhang wartet auf Sprachnachricht: %s (cmid=%s)", + file_name, file_cmid[:12] or "?") 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( - self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC) + self._flush_pending_files_after(self._pending_files_window()) ) try: @@ -3324,8 +3369,18 @@ class ARIABridge: focused_pid = str(payload.get("projectId") or "") if req_id: self._stt_stream_projects[req_id] = focused_pid - logger.info("[rvs] stt_stream_start id=%s focus=%s", - req_id[:12], focused_pid or "(main)") + # Absicht dieser Aufnahme: gehoert sie zu wartenden Anhaengen, + # und ist sie ein Zwischenruf statt einer neuen Anfrage? + att_cmid = str(payload.get("clientMsgId") or "") + is_interject = bool(payload.get("interject")) + if att_cmid or is_interject: + self._stt_stream_meta[req_id] = { + "cmid": att_cmid, "interject": is_interject, + } + logger.info("[rvs] stt_stream_start id=%s focus=%s%s%s", + req_id[:12], focused_pid or "(main)", + f" anhang-cmid={att_cmid[:12]}" if att_cmid else "", + " [ZWISCHENRUF]" if is_interject else "") return elif msg_type == "stt_stream_end": @@ -3340,10 +3395,10 @@ class ARIABridge: if req_id: try: loop = asyncio.get_event_loop() - loop.call_later( - 20.0, - lambda rid=req_id: self._stt_stream_projects.pop(rid, None), - ) + def _drop(rid: str = req_id) -> None: + self._stt_stream_projects.pop(rid, None) + self._stt_stream_meta.pop(rid, None) + loop.call_later(20.0, _drop) except Exception: # Kein laufender Loop (sollte im ws-Handler nie passieren) β€” # dann lieber gar nicht aufraeumen als crashen. @@ -3427,11 +3482,36 @@ class ARIABridge: # App-Version die projectId noch nicht mitschickt: leer = Hauptchat). stream_req_id = payload.get("requestId", "") or "" focused_pid = self._stt_stream_projects.pop(stream_req_id, "") + meta = self._stt_stream_meta.pop(stream_req_id, {}) + + # Zwischenruf per Sprache: in den LAUFENDEN Turn schieben, nicht als + # neue Anfrage anstellen. Gleicher Weg wie der πŸ“£-Knopf mit Text. + if meta.get("interject"): + logger.info("[rvs] stt_endpoint ist ein Zwischenruf β†’ project=%s: %s", + focused_pid or "(main)", text[:80]) + asyncio.create_task( + self._interject_proxy_for_project(focused_pid, text)) + # Den erkannten Text trotzdem broadcasten, damit die + # Sprach-Bubble in der App ihren Text bekommt statt ewig auf + # "wird verarbeitet..." zu stehen. + asyncio.create_task(self._send_to_rvs({ + "type": "chat", + "payload": { + "text": text, + "sender": "stt", + "audioRequestId": audio_request_id, + "projectId": focused_pid, + "interject": True, + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + })) + return asyncio.create_task(self._process_endpoint_text( text, interrupted, audio_request_id, location, client_msg_id=client_msg_id, - focused_project_id=focused_pid)) + focused_project_id=focused_pid, + attachment_client_msg_id=str(meta.get("cmid") or ""))) return elif msg_type == "oauth_callback": @@ -3891,7 +3971,8 @@ class ARIABridge: audio_request_id: str = "", location: Optional[dict] = None, client_msg_id: Optional[str] = None, - focused_project_id: str = "") -> None: + focused_project_id: str = "", + attachment_client_msg_id: str = "") -> None: """Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text schon ermittelt β€” wir uebernehmen den Pfad ab broadcast-STT + brain. @@ -3960,6 +4041,18 @@ class ARIABridge: except Exception as e: logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e) + # Sprachnachricht als Beschreibung zu Anhaengen: die liegen schon + # gepuffert (awaitVoice) und warten genau auf diesen Text β€” beides geht + # als EIN Turn ans Brain, sonst sieht ARIA ein Bild ohne Auftrag und + # danach einen Satz ohne Bild. + if attachment_client_msg_id and await self._flush_pending_files_with_text( + cleaned, project_id=project_id, + client_msg_id=attachment_client_msg_id, + require_match=True): + logger.info("[rvs] Sprachnachricht mit ihren Anhaengen gemerged (cmid=%s)", + attachment_client_msg_id[:12]) + return + core_text = self._build_core_text(cleaned, interrupted, location) await self.send_to_core(core_text, source="app-voice-stream" + (" [barge-in]" if interrupted else ""),