|
|
|
@@ -114,6 +114,16 @@ interface ChatMessage {
|
|
|
|
|
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
|
|
|
|
* wenn das chat_backup-Event vom Bridge zurueck kommt. */
|
|
|
|
|
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 ---
|
|
|
|
@@ -236,6 +246,7 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
|
|
|
|
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
|
|
|
|
const [inboxVisible, setInboxVisible] = useState(false);
|
|
|
|
|
const [showJumpDown, setShowJumpDown] = useState(false);
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [searchVisible, setSearchVisible] = useState(false);
|
|
|
|
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
|
|
|
@@ -259,6 +270,17 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
const flatListRef = useRef<FlatList>(null);
|
|
|
|
|
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
|
|
|
|
|
// file_response wird die Datei nach dem Speichern direkt mit dem System-
|
|
|
|
|
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
|
|
|
@@ -270,6 +292,98 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
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
|
|
|
|
|
// sofort greift, ohne Context- oder Event-System)
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -375,12 +489,24 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
const parsed: ChatMessage[] = JSON.parse(stored);
|
|
|
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
|
|
|
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 num = parseInt(msg.id.split('_').pop() || '0', 10);
|
|
|
|
|
return num > max ? num : max;
|
|
|
|
|
}, 0);
|
|
|
|
|
messageIdCounter.current = maxId;
|
|
|
|
|
messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
@@ -418,6 +544,22 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
// RVS-Nachrichten abonnieren
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
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
|
|
|
|
|
if (message.type === 'file_saved') {
|
|
|
|
|
const serverPath = (message.payload.serverPath as string) || '';
|
|
|
|
@@ -473,6 +615,10 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
mimeType: f.mimeType || '',
|
|
|
|
|
serverPath: f.serverPath || '',
|
|
|
|
|
})) 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 {
|
|
|
|
|
id: nextId(),
|
|
|
|
|
sender: role as 'user' | 'aria',
|
|
|
|
@@ -480,19 +626,32 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
timestamp: m.ts || Date.now(),
|
|
|
|
|
attachments: attachments.length ? attachments : 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);
|
|
|
|
|
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:
|
|
|
|
|
// - Skill-Created-Notifications (skillCreated gesetzt)
|
|
|
|
|
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
|
|
|
|
|
// 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 =>
|
|
|
|
|
m.skillCreated ||
|
|
|
|
|
m.triggerCreated ||
|
|
|
|
|
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)
|
|
|
|
|
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,
|
|
|
|
|
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
|
|
|
|
@@ -795,6 +963,21 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
setAgentActivity({ activity, tool });
|
|
|
|
|
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
|
|
|
|
// 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
|
|
|
|
@@ -838,6 +1021,7 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
const unsubState = rvs.onStateChange((state) => {
|
|
|
|
|
setConnectionState(state);
|
|
|
|
|
connectionStateRef.current = state;
|
|
|
|
|
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
|
|
|
|
|
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
|
|
|
|
|
// 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.
|
|
|
|
|
if (state === 'connected') {
|
|
|
|
|
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
|
|
|
|
|
setConnectionState(rvs.getState());
|
|
|
|
|
const initialState = rvs.getState();
|
|
|
|
|
setConnectionState(initialState);
|
|
|
|
|
connectionStateRef.current = initialState;
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
unsubMessage();
|
|
|
|
@@ -1051,26 +1250,60 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
setSearchIndex(0);
|
|
|
|
|
}, [searchQuery]);
|
|
|
|
|
|
|
|
|
|
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
|
|
|
|
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
|
|
|
|
|
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
|
|
|
|
|
// damit Layout sicher fertig ist.
|
|
|
|
|
// Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
|
|
|
|
|
// neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages
|
|
|
|
|
// aendert sich, soll aber nicht den Scroll erneut triggern).
|
|
|
|
|
const lastSearchScrollKey = useRef<string>('');
|
|
|
|
|
// Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
|
|
|
|
|
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
|
|
|
|
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
|
|
|
|
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const clearPendingScrollRetry = () => {
|
|
|
|
|
if (pendingScrollRetry.current) {
|
|
|
|
|
clearTimeout(pendingScrollRetry.current);
|
|
|
|
|
pendingScrollRetry.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
|
|
|
|
// 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) return;
|
|
|
|
|
if (!searchMatchIds.length) {
|
|
|
|
|
lastSearchScrollKey.current = '';
|
|
|
|
|
clearPendingScrollRetry();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const id = searchMatchIds[searchIndex];
|
|
|
|
|
if (!id) return;
|
|
|
|
|
const idx = invertedMessages.findIndex(m => m.id === id);
|
|
|
|
|
// 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;
|
|
|
|
|
const tryScroll = () => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
try {
|
|
|
|
|
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
|
|
|
|
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
|
|
|
|
} catch {
|
|
|
|
|
// wird von onScrollToIndexFailed nochmal versucht
|
|
|
|
|
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
|
|
|
|
|
requestAnimationFrame(tryScroll);
|
|
|
|
|
}, [searchIndex, searchMatchIds, invertedMessages]);
|
|
|
|
|
});
|
|
|
|
|
}, [searchIndex, searchMatchIds]);
|
|
|
|
|
|
|
|
|
|
// Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
|
|
|
|
|
useEffect(() => () => {
|
|
|
|
|
clearPendingScrollRetry();
|
|
|
|
|
clearStuckWatchdog();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const activeSearchId = searchMatchIds[searchIndex] || '';
|
|
|
|
|
const gotoSearchPrev = () => {
|
|
|
|
@@ -1150,29 +1383,33 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
const wasInterrupted = interruptAriaIfBusy();
|
|
|
|
|
const location = await getCurrentLocation();
|
|
|
|
|
|
|
|
|
|
const cmid = nextClientMsgId();
|
|
|
|
|
const userMsg: ChatMessage = {
|
|
|
|
|
id: nextId(),
|
|
|
|
|
sender: 'user',
|
|
|
|
|
text,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
clientMsgId: cmid,
|
|
|
|
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
|
|
|
sendAttempts: 1,
|
|
|
|
|
};
|
|
|
|
|
setMessages(prev => capMessages([...prev, userMsg]));
|
|
|
|
|
|
|
|
|
|
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
|
|
|
|
|
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
|
|
|
|
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
|
|
|
|
|
rvs.send('chat', {
|
|
|
|
|
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
|
|
|
|
|
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
|
|
|
|
dispatchWithAck(cmid, 'chat', {
|
|
|
|
|
text,
|
|
|
|
|
voice: localXttsVoiceRef.current,
|
|
|
|
|
speed: ttsSpeedRef.current,
|
|
|
|
|
interrupted: wasInterrupted,
|
|
|
|
|
...(location && { location }),
|
|
|
|
|
});
|
|
|
|
|
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
|
|
|
|
|
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
|
|
|
|
|
|
|
|
|
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
|
|
|
|
const cancelRequest = useCallback(() => {
|
|
|
|
|
setAgentActivity({ activity: 'idle', tool: '' });
|
|
|
|
|
clearStuckWatchdog();
|
|
|
|
|
rvs.send('cancel_request' as any, {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
@@ -1189,6 +1426,7 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
|
|
|
|
if (thinking) {
|
|
|
|
|
setAgentActivity({ activity: 'idle', tool: '' });
|
|
|
|
|
clearStuckWatchdog();
|
|
|
|
|
rvs.send('cancel_request' as any, {});
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
@@ -1201,16 +1439,20 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
const location = await getCurrentLocation();
|
|
|
|
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
|
|
|
|
|
|
|
|
|
const cmid = nextClientMsgId();
|
|
|
|
|
const userMsg: ChatMessage = {
|
|
|
|
|
id: nextId(),
|
|
|
|
|
sender: 'user',
|
|
|
|
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
audioRequestId,
|
|
|
|
|
clientMsgId: cmid,
|
|
|
|
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
|
|
|
sendAttempts: 1,
|
|
|
|
|
};
|
|
|
|
|
setMessages(prev => capMessages([...prev, userMsg]));
|
|
|
|
|
|
|
|
|
|
rvs.send('audio', {
|
|
|
|
|
dispatchWithAck(cmid, 'audio', {
|
|
|
|
|
base64: result.base64,
|
|
|
|
|
durationMs: result.durationMs,
|
|
|
|
|
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 = {
|
|
|
|
|
id: msgId,
|
|
|
|
|
sender: 'user',
|
|
|
|
|
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
attachments,
|
|
|
|
|
...(cmid && {
|
|
|
|
|
clientMsgId: cmid,
|
|
|
|
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
|
|
|
sendAttempts: 1,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
setMessages(prev => capMessages([...prev, userMsg]));
|
|
|
|
|
|
|
|
|
@@ -1311,9 +1560,11 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
|
|
|
|
|
if (messageText) {
|
|
|
|
|
rvs.send('chat', {
|
|
|
|
|
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
|
|
|
|
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
|
|
|
|
// adressieren.
|
|
|
|
|
if (messageText && cmid) {
|
|
|
|
|
dispatchWithAck(cmid, 'chat', {
|
|
|
|
|
text: messageText,
|
|
|
|
|
voice: localXttsVoiceRef.current,
|
|
|
|
|
speed: ttsSpeedRef.current,
|
|
|
|
@@ -1323,7 +1574,7 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
setPendingAttachments([]);
|
|
|
|
|
setInputText('');
|
|
|
|
|
}, [pendingAttachments, getCurrentLocation]);
|
|
|
|
|
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
|
|
|
|
|
|
|
|
|
// --- Rendering ---
|
|
|
|
|
|
|
|
|
@@ -1590,7 +1841,31 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
) : null}
|
|
|
|
|
<Text style={styles.timestamp}>{time}</Text>
|
|
|
|
|
<View style={styles.statusRow}>
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
@@ -1726,15 +2001,26 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
inverted
|
|
|
|
|
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) => {
|
|
|
|
|
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
|
|
|
|
|
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
|
|
|
|
|
// praezise nochmal versuchen.
|
|
|
|
|
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
|
|
|
|
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
|
|
|
|
|
// 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;
|
|
|
|
|
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
|
|
|
|
|
}, 250);
|
|
|
|
|
clearPendingScrollRetry();
|
|
|
|
|
pendingScrollRetry.current = setTimeout(() => {
|
|
|
|
|
pendingScrollRetry.current = null;
|
|
|
|
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
|
|
|
|
}, 300);
|
|
|
|
|
}}
|
|
|
|
|
keyExtractor={item => item.id}
|
|
|
|
|
renderItem={renderMessage}
|
|
|
|
@@ -1801,6 +2087,24 @@ const ChatScreen: React.FC = () => {
|
|
|
|
|
</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 */}
|
|
|
|
|
<View style={styles.inputContainer}>
|
|
|
|
|
{/* Datei-Buttons */}
|
|
|
|
@@ -2139,6 +2443,35 @@ const styles = StyleSheet.create({
|
|
|
|
|
marginTop: 4,
|
|
|
|
|
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: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
@@ -2341,6 +2674,23 @@ const styles = StyleSheet.create({
|
|
|
|
|
color: '#555570',
|
|
|
|
|
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: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 4,
|
|
|
|
|