Compare commits

...
Author SHA1 Message Date
duffyduck e628170760 release(agent): bump to 0.0.1.3 2026-10-03 10:16:20 +02:00
duffyduck 7af0b76ad3 release: bump version to 0.2.5.5 2026-10-02 07:35:59 +02:00
duffyduckandClaude Opus 5 614ba48531 feat(app): Sprachnachricht zu Anhaengen + Zwischenruf per Sprache
Bisher war die Eingabeleiste ein Entweder-oder: sobald Text im Feld stand oder
ein Anhang dranhing, verschwand das Mikro. Damit ging genau das nicht, was man
eigentlich will — ein Bild anhaengen und den Auftrag dazu SPRECHEN. Und ein
Zwischenruf ging nur getippt.

Das Mikro ist jetzt immer da; Senden-Pfeil und Mund-/Ohr-Knoepfe weichen je
nach Lage, damit die Zeile nicht ueberlaeuft.

Anhang + Sprachnachricht:
- Mikro antippen mit wartenden Anhaengen laedt sie SOFORT hoch (parallel zum
  Reden) und markiert sie mit awaitVoice + clientMsgId.
- Die Bridge schickt solche Anhaenge nicht nach 0.8s alleine ans Brain, sondern
  wartet auf den stt_endpoint und fuehrt Anhang + gesprochenen Satz zu EINEM
  Turn zusammen. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz
  ohne Bild.
- Der Merge ist strikt an die clientMsgId gebunden (require_match): eine
  Sprachnachricht, die zufaellig waehrend eines fremden Uploads fertig wird,
  reisst dessen Anhaenge nicht an sich. Der Text-Pfad behaelt seinen Fallback.
- Bild und gesprochener Text landen in EINER Bubble (gemeinsame audioRequestId).
- Klappt das Mikro nicht, bleibt die Bubble mit den Bildern stehen statt zu
  verschwinden — die Dateien sind ja schon auf dem Server.
- Das Notfall-Fenster (420s) liegt ueber dem Aufnahme-Hard-Cap von 5 Minuten,
  sonst waeren die Anhaenge bei einer langen Aufnahme vorher alleine raus.

Zwischenruf per Sprache:
- Der 📣-Knopf erscheint jetzt immer, wenn ARIA im Kontext arbeitet. Mit Text
  schickt er sofort (wie bisher), ohne Text schaltet er die naechste Aufnahme
  scharf (sichtbar hervorgehoben, gilt fuer genau eine Aufnahme).
- Eine so markierte Aufnahme geht per stt_stream_start als interject raus; die
  Bridge schiebt sie in den laufenden Turn statt sie anzustellen.
- Dabei wird der laufende Turn NICHT abgebrochen (das war der Sinn) — nur die
  Wiedergabe stoppt, damit ARIA nicht ins offene Mikro redet.
- Die Bubble zeigt 📣 Zwischenruf statt 🎤, damit man nicht auf eine eigene
  Antwort wartet.

Merge-Logik gegen den echten Bridge-Code getestet (6 Faelle), tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 02:11:06 +02:00
7 changed files with 281 additions and 60 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20504
versionName "0.2.5.4"
versionCode 20505
versionName "0.2.5.5"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.4",
"version": "0.2.5.5",
"private": true,
"scripts": {
"android": "react-native run-android",
+162 -43
View File
@@ -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<void>;
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<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
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<SendAttachmentsFn | null>(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<boolean> => {
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' ? (
<TouchableOpacity
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
onPress={() => {
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'}
>
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
</TouchableOpacity>
) : null}
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
{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' ? (
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
</>
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
) : (
<>
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
{/* 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,
},
+9
View File
@@ -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();
+104 -11
View File
@@ -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 ""),
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 102
versionName '0.0.1.2'
versionCode 103
versionName '0.0.1.3'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
+1 -1
View File
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.1.2"
AGENT_VERSION = "0.0.1.3"
HEARTBEAT_SEC = 25