Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c224562423 | |||
| 5c07aef526 | |||
| d54d37061f | |||
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd |
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user