Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck c224562423 release: bump version to 0.1.4.5 2026-05-14 23:38:45 +02:00
duffyduck 5c07aef526 fix(chat): Offline-Bubble verschwand nach Reconnect — clientMsgId-Dedup
Race-Bug nach Etappe 3: Beim Reconnect schickt die App parallel
chat_history_request und (via flushQueuedMessages) die offline gestaute
Nachricht. Die history_response kam an bevor die Bridge die Bubble in
chat_backup.jsonl geschrieben hatte → Server-Liste ohne unsere Bubble →
Merge ersetzte den lokalen Stand → Bubble weg (im Diagnostic war sie
gleich danach drin).

Bridge: _append_chat_backup nimmt clientMsgId mit auf. send_to_core
reicht sie als kwarg durch (chat- und audio-Pfad).

App: chat_history_response-Merge dedupt per clientMsgId. Lokale User-
Bubbles deren clientMsgId der Server noch nicht kennt bleiben erhalten
(localOnly-Filter erweitert). Server-User-Bubbles mit clientMsgId
kriegen deliveryStatus='delivered' damit das ✓✓ auch nach Reload sichtbar
bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:14:11 +02:00
duffyduck d54d37061f release: bump version to 0.1.4.4 2026-05-14 23:09:08 +02:00
duffyduck a6afec0e11 release: bump version to 0.1.4.3 2026-05-14 22:59:25 +02:00
duffyduck 205112021b fix(chat): Such-Scroll + Doppel-Send-Hang + Delivery-Handshake
Drei Etappen Chat-Fixes:

Etappe 1 — Such-Scroll permanent springen weg:
- invertedMessages raus aus dem useEffect-Deps; neue ARIA-Nachrichten triggern den Scroll-Effect nicht mehr. Aktueller Snapshot via Ref.
- onScrollToIndexFailed: statt 3 cascading Retries (120/320/600ms) nur noch EINE Retry nach 300ms. Cascading-Retries waren der Endlos-Cascade-Bug (jeder Failed-Retry triggerte 3 weitere).

Etappe 2 — AsyncStorage-Race + Stuck-Thinking:
- Init-Load merged statt overwrite — Nachrichten die zwischen Mount und Load-Done reinkommen werden nicht mehr verschluckt.
- Stuck-Thinking-Watchdog: 180s ohne agent_activity-Update → Auto-Reset auf idle + Timeout-Bubble. Gegen "App haengt auf 'ARIA denkt'".

Etappe 3 — Delivery-Handshake (WhatsApp-Style):
- Pro User-Bubble: clientMsgId + deliveryStatus (queued/sending/sent/delivered/failed).
- Offline-Queue: Send waehrend disconnected → 'queued' → flush bei Reconnect.
- Bridge sendet chat_ack zurueck → Bubble auf 'sent' (✓).
- ARIA-Reply → alle vorigen User-Bubbles 'delivered' (✓✓).
- ACK-Timeout 30s, bis zu 3 Retries, danach 'failed' (rotes Tap-fuer-Retry).
- Bridge: LRU-Idempotenz (200 cmids) verhindert Doppelte beim Retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:55:44 +02:00
duffyduck 853f2737f1 release: bump version to 0.1.4.2 2026-05-14 22:29:31 +02:00
duffyduck 7c61107f87 release: bump version to 0.1.4.1 2026-05-14 22:16:17 +02:00
duffyduck 7a22474efd feat(chat): Jump-down-Button + Sprung-an-Text-Anfang + Vision-Issue raus
Drei kleine UX-Fixes im Chat:

1. Jump-Down-Button (↓): Bei inverted FlatList erscheint rechts ueber
   der Eingabe ein blauer FAB, sobald man mehr als 250px von der
   neuesten Nachricht weg gescrollt ist. Tap → scrollToOffset(0)
   animated → wieder unten. Auto-hide wenn man unten ist.

2. Such-Sprung landet jetzt am TEXT-ANFANG der Treffer-Bubble:
   viewPosition 0.5 (Mitte) → 0 (Item-Top am Viewport-Top). Plus
   Retry-Folge (180/420/800ms) gegen Layout-Race bei langen Listen.
   Vorher musste man oft nochmal hoch scrollen um den Anfang zu sehen.
   onScrollToIndexFailed-Fallback genauso mit viewPosition 0.

3. issue.md: "Bilder: Claude Vision direkt nutzen" raus aus den
   offenen Punkten — ist durch Stufe E (Memory-Anhaenge, Read-Tool
   multi-modal) längst geloest. ARIA sieht Bilder echt.

Folge-Etappen: Such-Sprung-Resilienz war Teil davon (mehrere Retries
abgedeckt). Naechste Brocken: Doppel-Send-Haenger, AsyncStorage-Race,
Offline-Queue mit Idempotenz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:10:26 +02:00
5 changed files with 458 additions and 47 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10400 versionCode 10405
versionName "0.1.4.0" versionName "0.1.4.5"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.4.0", "version": "0.1.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+390 -40
View File
@@ -114,6 +114,16 @@ interface ChatMessage {
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst * sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
* wenn das chat_backup-Event vom Bridge zurueck kommt. */ * wenn das chat_backup-Event vom Bridge zurueck kommt. */
backupTs?: number; backupTs?: number;
/** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue,
* ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und
* durch die Bridge zurueck-gespiegelt. */
clientMsgId?: string;
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
sendAttempts?: number;
} }
// --- Konstanten --- // --- Konstanten ---
@@ -236,6 +246,7 @@ const ChatScreen: React.FC = () => {
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null); const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null); const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
const [inboxVisible, setInboxVisible] = useState(false); const [inboxVisible, setInboxVisible] = useState(false);
const [showJumpDown, setShowJumpDown] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
@@ -259,6 +270,17 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
// verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset.
const stuckWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearStuckWatchdog = () => {
if (stuckWatchdog.current) {
clearTimeout(stuckWatchdog.current);
stuckWatchdog.current = null;
}
};
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim // ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
// file_response wird die Datei nach dem Speichern direkt mit dem System- // file_response wird die Datei nach dem Speichern direkt mit dem System-
// Intent geoeffnet (PDF-Viewer, Galerie, etc.). // Intent geoeffnet (PDF-Viewer, Galerie, etc.).
@@ -270,6 +292,98 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`; return `msg_${Date.now()}_${messageIdCounter.current}`;
}; };
// Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry,
// Idempotenz). Format: cmsg_<ms>_<rand> — eindeutig genug fuer eine
// 100er-Dedup-Window auf der Bridge.
const nextClientMsgId = (): string =>
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
const ACK_TIMEOUT_MS = 30_000;
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
const MAX_SEND_ATTEMPTS = 3;
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
const ackTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearAckTimer = (cmid: string) => {
const t = ackTimers.current.get(cmid);
if (t) {
clearTimeout(t);
ackTimers.current.delete(cmid);
}
};
// Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach
// ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref
// damit der Inhalt Closures ueberlebt.
const pendingPayloads = useRef<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());
// ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
// die sonst auf einen veralteten Wert zugreifen wuerden.
const connectionStateRef = useRef<ConnectionState>('disconnected');
// Status einer Bubble per clientMsgId aendern (Helper)
const updateMessageStatus = useCallback(
(cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m)));
},
[],
);
// Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking.
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
const dispatchWithAck = useCallback(
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
pendingPayloads.current.set(cmid, { type, payload });
const online = connectionStateRef.current === 'connected';
if (!online) {
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
return;
}
// RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck
rvs.send(type, { ...payload, clientMsgId: cmid });
updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt });
clearAckTimer(cmid);
ackTimers.current.set(
cmid,
setTimeout(() => {
ackTimers.current.delete(cmid);
if (attempt >= MAX_SEND_ATTEMPTS) {
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
} else {
console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1);
dispatchWithAck(cmid, type, payload, attempt + 1);
}
}, ACK_TIMEOUT_MS),
);
},
[updateMessageStatus],
);
// Alle 'queued'-Nachrichten beim Reconnect rausschicken
const flushQueuedMessages = useCallback(() => {
setMessages(prev => {
for (const m of prev) {
if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue;
const pending = pendingPayloads.current.get(m.clientMsgId);
if (!pending) continue;
// Versuchszaehler beibehalten (oder mit 1 starten falls leer)
dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1);
}
return prev;
});
}, [dispatchWithAck]);
// Manueller Retry nach 'failed' (tap auf das ⚠️-Icon)
const retryFailedMessage = useCallback((cmid: string) => {
const pending = pendingPayloads.current.get(cmid);
if (!pending) return;
dispatchWithAck(cmid, pending.type, pending.payload, 1);
}, [dispatchWithAck]);
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System) // sofort greift, ohne Context- oder Event-System)
useEffect(() => { useEffect(() => {
@@ -375,12 +489,24 @@ const ChatScreen: React.FC = () => {
const parsed: ChatMessage[] = JSON.parse(stored); const parsed: ChatMessage[] = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
console.log('[Chat] ${parsed.length} Nachrichten geladen'); console.log('[Chat] ${parsed.length} Nachrichten geladen');
setMessages(parsed); // MERGE statt Overwrite: zwischen Mount und Load-Done koennen
// bereits Nachrichten ankommen (User schreibt sofort, WS-Events
// kommen vor Load-Ende). Vorher hat setMessages(parsed) diese
// ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir
// per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes
// (die sind frischer).
setMessages(prev => {
if (prev.length === 0) return parsed;
const byId = new Map<string, ChatMessage>();
for (const m of parsed) byId.set(m.id, m);
for (const m of prev) byId.set(m.id, m);
return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
});
const maxId = parsed.reduce((max, msg) => { const maxId = parsed.reduce((max, msg) => {
const num = parseInt(msg.id.split('_').pop() || '0', 10); const num = parseInt(msg.id.split('_').pop() || '0', 10);
return num > max ? num : max; return num > max ? num : max;
}, 0); }, 0);
messageIdCounter.current = maxId; messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
} }
} }
} catch (err) { } catch (err) {
@@ -418,6 +544,22 @@ const ChatScreen: React.FC = () => {
// RVS-Nachrichten abonnieren // RVS-Nachrichten abonnieren
useEffect(() => { useEffect(() => {
const unsubMessage = rvs.onMessage((message: RVSMessage) => { const unsubMessage = rvs.onMessage((message: RVSMessage) => {
// chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht.
// Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer.
if (message.type === ('chat_ack' as any)) {
const cmid = (message.payload as any).clientMsgId as string | undefined;
if (cmid) {
clearAckTimer(cmid);
pendingPayloads.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus !== 'delivered'
? { ...m, deliveryStatus: 'sent' }
: m
));
}
return;
}
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download // file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
if (message.type === 'file_saved') { if (message.type === 'file_saved') {
const serverPath = (message.payload.serverPath as string) || ''; const serverPath = (message.payload.serverPath as string) || '';
@@ -473,6 +615,10 @@ const ChatScreen: React.FC = () => {
mimeType: f.mimeType || '', mimeType: f.mimeType || '',
serverPath: f.serverPath || '', serverPath: f.serverPath || '',
})) as Attachment[]; })) as Attachment[];
// clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup,
// damit wir lokale Bubbles per ID dedupen koennen statt nur per
// Text/Timestamp-Heuristik.
const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined;
return { return {
id: nextId(), id: nextId(),
sender: role as 'user' | 'aria', sender: role as 'user' | 'aria',
@@ -480,19 +626,32 @@ const ChatScreen: React.FC = () => {
timestamp: m.ts || Date.now(), timestamp: m.ts || Date.now(),
attachments: attachments.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
backupTs: typeof m.ts === 'number' ? m.ts : undefined, backupTs: typeof m.ts === 'number' ? m.ts : undefined,
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
}; };
}); });
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0); const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
setMessages(prev => { setMessages(prev => {
// ClientMsgIds die der Server kennt — lokale Bubbles mit der
// gleichen ID werden durch die Server-Version ersetzt.
const serverCmids = new Set(
fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x)
);
// Lokal-only Bubbles erkennen + behalten: // Lokal-only Bubbles erkennen + behalten:
// - Skill-Created-Notifications (skillCreated gesetzt) // - Skill-Created-Notifications (skillCreated gesetzt)
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId // - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
// gesetzt UND text leer/Placeholder) // gesetzt UND text leer/Placeholder)
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
// noch laeuft. Ohne diesen Schutz haette der history_response
// die gerade reaktivierten Offline-Nachrichten geloescht.
const localOnly = prev.filter(m => const localOnly = prev.filter(m =>
m.skillCreated || m.skillCreated ||
m.triggerCreated || m.triggerCreated ||
m.memorySaved || m.memorySaved ||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) ||
(m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId))
); );
// Server-Stand + lokal-only (chronologisch sortiert) // Server-Stand + lokal-only (chronologisch sortiert)
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp); const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
@@ -749,8 +908,17 @@ const ChatScreen: React.FC = () => {
messageId: (message.payload.messageId as string) || undefined, messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined, backupTs: (message.payload.backupTs as number) || undefined,
}; };
return capMessages([...prev, ariaMsg]); // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m =>
m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' }
: m
);
}); });
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog();
} }
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
@@ -795,6 +963,21 @@ const ChatScreen: React.FC = () => {
setAgentActivity({ activity, tool }); setAgentActivity({ activity, tool });
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert // Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus). // nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
// activity-Event), Timer neu starten. 180s ohne Update → Hang.
clearStuckWatchdog();
if (activity !== 'idle') {
stuckWatchdog.current = setTimeout(() => {
stuckWatchdog.current = null;
setAgentActivity({ activity: 'idle', tool: '' });
setMessages(prev => capMessages([...prev, {
id: nextId(),
sender: 'aria',
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 3 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
timestamp: Date.now(),
}]));
}, 180_000);
}
} }
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -838,6 +1021,7 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => { const unsubState = rvs.onStateChange((state) => {
setConnectionState(state); setConnectionState(state);
connectionStateRef.current = state;
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die // Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation // Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline // zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
@@ -845,11 +1029,26 @@ const ChatScreen: React.FC = () => {
// Nachrichten vom Server, oder leeres Array wenn Server leer. // Nachrichten vom Server, oder leeres Array wenn Server leer.
if (state === 'connected') { if (state === 'connected') {
rvs.send('chat_history_request' as any, { since: 0, limit: 200 }); rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
// Offline-Queue flushen — alle 'queued'-Bubbles raussschicken
flushQueuedMessages();
} else if (state === 'disconnected') {
// ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck
for (const [cmid, t] of ackTimers.current.entries()) {
clearTimeout(t);
ackTimers.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus === 'sending'
? { ...m, deliveryStatus: 'queued' }
: m
));
}
} }
}); });
// Initalen Status setzen // Initalen Status setzen
setConnectionState(rvs.getState()); const initialState = rvs.getState();
setConnectionState(initialState);
connectionStateRef.current = initialState;
return () => { return () => {
unsubMessage(); unsubMessage();
@@ -1051,26 +1250,60 @@ const ChatScreen: React.FC = () => {
setSearchIndex(0); setSearchIndex(0);
}, [searchQuery]); }, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen. // Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render // neue Nachrichten kommen waehrend Suche aktiv ist invertedMessages
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal // aendert sich, soll aber nicht den Scroll erneut triggern).
// damit Layout sicher fertig ist. const lastSearchScrollKey = useRef<string>('');
useEffect(() => { // Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
if (!searchMatchIds.length) return; // ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
const id = searchMatchIds[searchIndex]; // Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
if (!id) return; const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
const idx = invertedMessages.findIndex(m => m.id === id); const clearPendingScrollRetry = () => {
if (idx < 0 || !flatListRef.current) return; if (pendingScrollRetry.current) {
const tryScroll = () => { clearTimeout(pendingScrollRetry.current);
try { pendingScrollRetry.current = null;
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch {
// wird von onScrollToIndexFailed nochmal versucht
} }
}; };
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
requestAnimationFrame(tryScroll); // Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
}, [searchIndex, searchMatchIds, invertedMessages]); // FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar.
// WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das
// Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok.
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
const invertedMessagesRef = useRef(invertedMessages);
invertedMessagesRef.current = invertedMessages;
useEffect(() => {
if (!searchMatchIds.length) {
lastSearchScrollKey.current = '';
clearPendingScrollRetry();
return;
}
const id = searchMatchIds[searchIndex];
if (!id) return;
// Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische
// Re-Renders erneut scrollen.
const key = `${searchIndex}:${id}`;
if (lastSearchScrollKey.current === key) return;
lastSearchScrollKey.current = key;
// Neue Suche → alte Retries verwerfen
clearPendingScrollRetry();
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
requestAnimationFrame(() => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
} catch {
// onScrollToIndexFailed-Handler uebernimmt den Fallback
}
});
}, [searchIndex, searchMatchIds]);
// Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
useEffect(() => () => {
clearPendingScrollRetry();
clearStuckWatchdog();
}, []);
const activeSearchId = searchMatchIds[searchIndex] || ''; const activeSearchId = searchMatchIds[searchIndex] || '';
const gotoSearchPrev = () => { const gotoSearchPrev = () => {
@@ -1150,29 +1383,33 @@ const ChatScreen: React.FC = () => {
const wasInterrupted = interruptAriaIfBusy(); const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text, text,
timestamp: Date.now(), timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s', console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted); cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort) dispatchWithAck(cmid, 'chat', {
rvs.send('chat', {
text, text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted, interrupted: wasInterrupted,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix // Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => { const cancelRequest = useCallback(() => {
setAgentActivity({ activity: 'idle', tool: '' }); setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {}); rvs.send('cancel_request' as any, {});
}, []); }, []);
@@ -1189,6 +1426,7 @@ const ChatScreen: React.FC = () => {
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)'); if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) { if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' }); setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {}); rvs.send('cancel_request' as any, {});
} }
return true; return true;
@@ -1201,16 +1439,20 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const cmid = nextClientMsgId();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
audioRequestId, audioRequestId,
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', { dispatchWithAck(cmid, 'audio', {
base64: result.base64, base64: result.base64,
durationMs: result.durationMs, durationMs: result.durationMs,
mimeType: result.mimeType, mimeType: result.mimeType,
@@ -1271,13 +1513,20 @@ const ChatScreen: React.FC = () => {
}); });
} }
// Chat-Nachricht mit allen Anhaengen // 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;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: msgId, id: msgId,
sender: 'user', sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`, text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(), timestamp: Date.now(),
attachments, attachments,
...(cmid && {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}),
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
@@ -1311,9 +1560,11 @@ const ChatScreen: React.FC = () => {
}); });
} }
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) // Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
if (messageText) { // dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
rvs.send('chat', { // adressieren.
if (messageText && cmid) {
dispatchWithAck(cmid, 'chat', {
text: messageText, text: messageText,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
@@ -1323,7 +1574,7 @@ const ChatScreen: React.FC = () => {
setPendingAttachments([]); setPendingAttachments([]);
setInputText(''); setInputText('');
}, [pendingAttachments, getCurrentLocation]); }, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// --- Rendering --- // --- Rendering ---
@@ -1590,7 +1841,31 @@ const ChatScreen: React.FC = () => {
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text> <Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
<View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text> <Text style={styles.timestamp}>{time}</Text>
{isUser && item.deliveryStatus ? (
item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
>
<Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
</TouchableOpacity>
) : (
<Text style={
item.deliveryStatus === 'queued' ? styles.statusQueued :
item.deliveryStatus === 'sending' ? styles.statusSending :
item.deliveryStatus === 'sent' ? styles.statusSent :
/* delivered */ styles.statusDelivered
}>
{item.deliveryStatus === 'queued' ? '⏱' :
item.deliveryStatus === 'sending' ? '⏳' :
item.deliveryStatus === 'sent' ? '✓' :
/* delivered */ '✓✓'}
</Text>
)
) : null}
</View>
</View> </View>
); );
}; };
@@ -1726,15 +2001,26 @@ const ChatScreen: React.FC = () => {
ref={flatListRef} ref={flatListRef}
inverted inverted
data={invertedMessages} data={invertedMessages}
onScroll={(e) => {
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
const y = e.nativeEvent.contentOffset.y;
setShowJumpDown(y > 250);
}}
scrollEventThrottle={120}
onScrollToIndexFailed={(info) => { onScrollToIndexFailed={(info) => {
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die // FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms // die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
// praezise nochmal versuchen. // nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
// Scrolls in der Pipeline = der "permanent springen"-Bug).
const offset = info.averageItemLength * info.index; const offset = info.averageItemLength * info.index;
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {} try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
setTimeout(() => { clearPendingScrollRetry();
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {} pendingScrollRetry.current = setTimeout(() => {
}, 250); pendingScrollRetry.current = null;
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
}, 300);
}} }}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={renderMessage} renderItem={renderMessage}
@@ -1801,6 +2087,24 @@ const ChatScreen: React.FC = () => {
</View> </View>
)} )}
{/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten
Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset
0 == neueste Nachricht visuell unten. */}
{showJumpDown && (
<TouchableOpacity
style={styles.jumpDownBtn}
activeOpacity={0.85}
onPress={() => {
try {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
} catch {}
setShowJumpDown(false);
}}
>
<Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
</TouchableOpacity>
)}
{/* Eingabebereich */} {/* Eingabebereich */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
{/* Datei-Buttons */} {/* Datei-Buttons */}
@@ -2139,6 +2443,35 @@ const styles = StyleSheet.create({
marginTop: 4, marginTop: 4,
alignSelf: 'flex-end', alignSelf: 'flex-end',
}, },
statusRow: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-end',
gap: 6,
marginTop: 4,
},
statusQueued: {
color: '#FFD60A', // Gelb — wartet auf Verbindung
fontSize: 11,
},
statusSending: {
color: 'rgba(255,255,255,0.5)',
fontSize: 11,
},
statusSent: {
color: 'rgba(255,255,255,0.6)',
fontSize: 12,
},
statusDelivered: {
color: '#34C759', // Gruen — Brain hat geantwortet
fontSize: 12,
fontWeight: '700',
},
statusFailed: {
color: '#FF3B30',
fontSize: 11,
fontWeight: '700',
},
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -2341,6 +2674,23 @@ const styles = StyleSheet.create({
color: '#555570', color: '#555570',
fontSize: 10, fontSize: 10,
}, },
jumpDownBtn: {
position: 'absolute',
right: 16,
bottom: 80,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
elevation: 5,
zIndex: 100,
},
bubbleTrash: { bubbleTrash: {
position: 'absolute', position: 'absolute',
top: 4, top: 4,
+69 -7
View File
@@ -25,6 +25,7 @@ import time
import sys import sys
import tempfile import tempfile
import uuid import uuid
from collections import OrderedDict
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -475,6 +476,13 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode() self.current_mode = self._load_persisted_mode()
self.running = False self.running = False
# Idempotenz: zuletzt gesehene clientMsgIds (App-seitig generiert).
# Beim Reconnect/Retry sendet die App dieselbe ID nochmal — wir
# antworten erneut mit ACK aber leiten NICHT doppelt an Brain weiter.
# OrderedDict als FIFO mit Capping (Insertion-Order).
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
self._SEEN_CLIENT_MSG_LIMIT = 200
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt) # Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
self.tts_enabled = True self.tts_enabled = True
self.xtts_voice = "" self.xtts_voice = ""
@@ -1311,7 +1319,7 @@ class ARIABridge:
await self.send_to_core(text, source="app-file+chat") await self.send_to_core(text, source="app-file+chat")
return True 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", client_msg_id: Optional[str] = None) -> None:
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort. """Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
@@ -1325,8 +1333,13 @@ class ARIABridge:
logger.info("[brain] chat ← %s '%s'", source, text[:80]) logger.info("[brain] chat ← %s '%s'", source, text[:80])
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect # User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
# / Diagnostic-Reload als History-Quelle gelesen. # / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
self._append_chat_backup({"role": "user", "text": text, "source": source}) # damit die App beim chat_history_response ihre lokale Bubble
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
entry: dict = {"role": "user", "text": text, "source": source}
if client_msg_id:
entry["clientMsgId"] = client_msg_id
self._append_chat_backup(entry)
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs # agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht. # damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
@@ -1530,6 +1543,36 @@ class ARIABridge:
except Exception: except Exception:
break break
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
App nach Timeout retryen — gegen Verlust bei Netz-Hicksern.
"""
if not client_msg_id:
return
await self._send_to_rvs({
"type": "chat_ack",
"payload": {"clientMsgId": client_msg_id},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
def _is_duplicate_client_msg(self, client_msg_id: Optional[str]) -> bool:
"""Prueft ob wir diese clientMsgId schon verarbeitet haben.
Wenn ja → True (Caller soll ACK senden aber NICHT an Brain forwarden).
Wenn nein → in den Seen-Cache aufnehmen + False zurueck.
"""
if not client_msg_id:
return False
if client_msg_id in self._seen_client_msg_ids:
logger.info("[rvs] Idempotenz: cmid=%s bereits verarbeitet, ignoriere",
client_msg_id)
return True
self._seen_client_msg_ids[client_msg_id] = time.time()
# Capping: aelteste Eintraege rauswerfen
while len(self._seen_client_msg_ids) > self._SEEN_CLIENT_MSG_LIMIT:
self._seen_client_msg_ids.popitem(last=False)
return False
async def _handle_rvs_message(self, raw_message: str) -> None: async def _handle_rvs_message(self, raw_message: str) -> None:
"""Verarbeitet Nachrichten von der App (via RVS). """Verarbeitet Nachrichten von der App (via RVS).
@@ -1554,6 +1597,13 @@ class ARIABridge:
sender = payload.get("sender", "") sender = payload.get("sender", "")
if sender in ("aria", "stt"): if sender in ("aria", "stt"):
return return
# Delivery-ACK: immer zurueckschicken (auch bei Idempotenz-Hit),
# damit die App den Status auf 'sent' setzen kann. Idempotenz-
# Check VERHINDERT aber die Doppel-Weiterleitung an Brain.
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
text = payload.get("text", "") text = payload.get("text", "")
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten # Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen). # chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
@@ -1589,7 +1639,9 @@ class ARIABridge:
" [BARGE-IN]" if interrupted else "", " [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "", " [GPS]" if location else "",
text[:80]) text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else "")) await self.send_to_core(core_text,
source="app" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
return return
if msg_type == "cancel_request": if msg_type == "cancel_request":
@@ -2153,6 +2205,12 @@ class ARIABridge:
elif msg_type == "audio": elif msg_type == "audio":
# Audio von der App → decodieren → STT → an aria-core # Audio von der App → decodieren → STT → an aria-core
# Delivery-ACK + Idempotenz wie bei chat — App nutzt die ACKs
# auch fuer Sprach-Bubbles (Status auf der Bubble: ✓ sent).
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
audio_b64 = payload.get("base64", "") audio_b64 = payload.get("base64", "")
mime_type = payload.get("mimeType", "audio/mp4") mime_type = payload.get("mimeType", "audio/mp4")
duration_ms = payload.get("durationMs", 0) duration_ms = payload.get("durationMs", 0)
@@ -2183,7 +2241,8 @@ class ARIABridge:
" [GPS]" if location else "", " [GPS]" if location else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "") f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio( asyncio.create_task(self._process_app_audio(
audio_b64, mime_type, interrupted, audio_request_id, location)) audio_b64, mime_type, interrupted, audio_request_id, location,
client_msg_id=client_msg_id))
elif msg_type == "stt_response": elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request # Antwort der whisper-bridge auf unseren stt_request
@@ -2242,7 +2301,8 @@ class ARIABridge:
async def _process_app_audio(self, audio_b64: str, mime_type: str, async def _process_app_audio(self, audio_b64: str, mime_type: str,
interrupted: bool = False, interrupted: bool = False,
audio_request_id: str = "", audio_request_id: str = "",
location: Optional[dict] = None) -> None: location: Optional[dict] = None,
client_msg_id: Optional[str] = None) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal. """App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
@@ -2298,7 +2358,9 @@ class ARIABridge:
# Dann an Brain — der blockt synchron bis ARIA fertig ist. # Dann an Brain — der blockt synchron bis ARIA fertig ist.
core_text = self._build_core_text(text, interrupted, location) core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else "")) await self.send_to_core(core_text,
source="app-voice" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
else: else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert") logger.info("[rvs] Keine Sprache erkannt — ignoriert")
-1
View File
@@ -348,7 +348,6 @@ Skills mit Tool-Use.
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild) - [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur ### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU) - [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen - [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push - [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push