@mobileai/react-native 0.9.10 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/lib/module/components/AIAgent.js +513 -36
- package/lib/module/components/AIAgent.js.map +1 -1
- package/lib/module/components/AgentChatBar.js +320 -13
- package/lib/module/components/AgentChatBar.js.map +1 -1
- package/lib/module/config/endpoints.js +22 -0
- package/lib/module/config/endpoints.js.map +1 -0
- package/lib/module/core/systemPrompt.js +126 -100
- package/lib/module/core/systemPrompt.js.map +1 -1
- package/lib/module/services/AudioInputService.js +9 -0
- package/lib/module/services/AudioInputService.js.map +1 -1
- package/lib/module/services/flags/FlagService.js +1 -1
- package/lib/module/services/flags/FlagService.js.map +1 -1
- package/lib/module/services/telemetry/TelemetryService.js +39 -13
- package/lib/module/services/telemetry/TelemetryService.js.map +1 -1
- package/lib/module/services/telemetry/device.js +80 -10
- package/lib/module/services/telemetry/device.js.map +1 -1
- package/lib/module/support/EscalationSocket.js +46 -7
- package/lib/module/support/EscalationSocket.js.map +1 -1
- package/lib/module/support/SupportChatModal.js +516 -0
- package/lib/module/support/SupportChatModal.js.map +1 -0
- package/lib/module/support/TicketStore.js +93 -0
- package/lib/module/support/TicketStore.js.map +1 -0
- package/lib/module/support/escalateTool.js +39 -13
- package/lib/module/support/escalateTool.js.map +1 -1
- package/lib/module/support/index.js.map +1 -1
- package/lib/typescript/src/components/AIAgent.d.ts +24 -1
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
- package/lib/typescript/src/components/AgentChatBar.d.ts +24 -2
- package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
- package/lib/typescript/src/config/endpoints.d.ts +18 -0
- package/lib/typescript/src/config/endpoints.d.ts.map +1 -0
- package/lib/typescript/src/core/systemPrompt.d.ts +4 -13
- package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -1
- package/lib/typescript/src/core/types.d.ts +1 -1
- package/lib/typescript/src/core/types.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useAction.d.ts +2 -2
- package/lib/typescript/src/hooks/useAction.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/services/AudioInputService.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/device.d.ts +15 -4
- package/lib/typescript/src/services/telemetry/device.d.ts.map +1 -1
- package/lib/typescript/src/support/EscalationSocket.d.ts +7 -1
- package/lib/typescript/src/support/EscalationSocket.d.ts.map +1 -1
- package/lib/typescript/src/support/SupportChatModal.d.ts +19 -0
- package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -0
- package/lib/typescript/src/support/TicketStore.d.ts +34 -0
- package/lib/typescript/src/support/TicketStore.d.ts.map +1 -0
- package/lib/typescript/src/support/escalateTool.d.ts +15 -1
- package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
- package/lib/typescript/src/support/index.d.ts +1 -1
- package/lib/typescript/src/support/index.d.ts.map +1 -1
- package/lib/typescript/src/support/types.d.ts +15 -0
- package/lib/typescript/src/support/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/AIAgent.tsx +507 -36
- package/src/components/AgentChatBar.tsx +355 -9
- package/src/config/endpoints.ts +22 -0
- package/src/core/systemPrompt.ts +126 -100
- package/src/core/types.ts +1 -1
- package/src/hooks/useAction.ts +2 -2
- package/src/index.ts +1 -0
- package/src/services/AudioInputService.ts +9 -0
- package/src/services/flags/FlagService.ts +1 -1
- package/src/services/telemetry/TelemetryService.ts +40 -13
- package/src/services/telemetry/device.ts +88 -11
- package/src/support/EscalationSocket.ts +47 -8
- package/src/support/SupportChatModal.tsx +527 -0
- package/src/support/TicketStore.ts +100 -0
- package/src/support/escalateTool.ts +47 -13
- package/src/support/index.ts +1 -0
- package/src/support/types.ts +14 -0
|
@@ -29,12 +29,16 @@ import { AudioInputService } from '../services/AudioInputService';
|
|
|
29
29
|
import { AudioOutputService } from '../services/AudioOutputService';
|
|
30
30
|
import { TelemetryService, bindTelemetryService } from '../services/telemetry';
|
|
31
31
|
import { extractTouchLabel, checkRageClick } from '../services/telemetry/TouchAutoCapture';
|
|
32
|
+
import { initDeviceId, getDeviceId } from '../services/telemetry/device';
|
|
32
33
|
import type { AgentConfig, AgentMode, ExecutionResult, ToolDefinition, AgentStep, TokenUsage, KnowledgeBaseConfig, ChatBarTheme, AIMessage, AIProviderName, ScreenMap, ProactiveHelpConfig } from '../core/types';
|
|
33
34
|
import { AgentErrorBoundary } from './AgentErrorBoundary';
|
|
34
35
|
import { HighlightOverlay } from './HighlightOverlay';
|
|
35
36
|
import { IdleDetector } from '../core/IdleDetector';
|
|
36
37
|
import { ProactiveHint } from './ProactiveHint';
|
|
37
38
|
import { createEscalateTool } from '../support/escalateTool';
|
|
39
|
+
import { EscalationSocket } from '../support/EscalationSocket';
|
|
40
|
+
import { SupportChatModal } from '../support/SupportChatModal';
|
|
41
|
+
import { ENDPOINTS } from '../config/endpoints';
|
|
38
42
|
|
|
39
43
|
// ─── Context ───────────────────────────────────────────────────
|
|
40
44
|
|
|
@@ -190,6 +194,34 @@ interface AIAgentProps {
|
|
|
190
194
|
* Proactive agent configuration (detects user hesitation)
|
|
191
195
|
*/
|
|
192
196
|
proactiveHelp?: ProactiveHelpConfig;
|
|
197
|
+
|
|
198
|
+
// ── Support Configuration ────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Identity of the logged-in user.
|
|
202
|
+
* If provided, this enforces "one ticket per user" and shows the user profile
|
|
203
|
+
* in the Dashboard (name, email, plan, etc.).
|
|
204
|
+
*/
|
|
205
|
+
userContext?: {
|
|
206
|
+
userId?: string;
|
|
207
|
+
name?: string;
|
|
208
|
+
email?: string;
|
|
209
|
+
phone?: string;
|
|
210
|
+
plan?: string;
|
|
211
|
+
custom?: Record<string, string | number | boolean>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Device push token for offline support replies.
|
|
216
|
+
* Use '@react-native-firebase/messaging' or 'expo-notifications' to get this.
|
|
217
|
+
*/
|
|
218
|
+
pushToken?: string;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The type of push token provided.
|
|
222
|
+
* "fcm" is recommended for universal bare/Expo support.
|
|
223
|
+
*/
|
|
224
|
+
pushTokenType?: 'fcm' | 'expo' | 'apns';
|
|
193
225
|
}
|
|
194
226
|
|
|
195
227
|
|
|
@@ -239,6 +271,9 @@ export function AIAgent({
|
|
|
239
271
|
analyticsProxyUrl,
|
|
240
272
|
analyticsProxyHeaders,
|
|
241
273
|
proactiveHelp,
|
|
274
|
+
userContext,
|
|
275
|
+
pushToken,
|
|
276
|
+
pushTokenType,
|
|
242
277
|
}: AIAgentProps) {
|
|
243
278
|
// Configure logger based on debug prop
|
|
244
279
|
React.useEffect(() => {
|
|
@@ -252,7 +287,61 @@ export function AIAgent({
|
|
|
252
287
|
const [isThinking, setIsThinking] = useState(false);
|
|
253
288
|
const [statusText, setStatusText] = useState('');
|
|
254
289
|
const [lastResult, setLastResult] = useState<ExecutionResult | null>(null);
|
|
290
|
+
const [lastUserMessage, setLastUserMessage] = useState<string | null>(null);
|
|
255
291
|
const [messages, setMessages] = useState<AIMessage[]>([]);
|
|
292
|
+
const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
|
|
293
|
+
|
|
294
|
+
// Increment scroll trigger when messages change to auto-scroll chat modal
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (messages.length > 0) {
|
|
297
|
+
setChatScrollTrigger(prev => prev + 1);
|
|
298
|
+
}
|
|
299
|
+
}, [messages.length]);
|
|
300
|
+
|
|
301
|
+
// ── Support Modal State ──
|
|
302
|
+
const [tickets, setTickets] = useState<import('../support/types').SupportTicket[]>([]);
|
|
303
|
+
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
|
304
|
+
const [supportSocket, setSupportSocket] = useState<EscalationSocket | null>(null);
|
|
305
|
+
const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
|
|
306
|
+
const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
|
|
307
|
+
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
|
308
|
+
// Ref mirrors selectedTicketId — lets socket callbacks access current value
|
|
309
|
+
// without stale closures (sockets are long-lived, closures capture old state).
|
|
310
|
+
const selectedTicketIdRef = useRef<string | null>(null);
|
|
311
|
+
useEffect(() => { selectedTicketIdRef.current = selectedTicketId; }, [selectedTicketId]);
|
|
312
|
+
// Cache of live sockets by ticketId — keeps sockets alive even when user
|
|
313
|
+
// navigates back to the ticket list, so new messages still trigger badge updates.
|
|
314
|
+
const pendingSocketsRef = useRef<Map<string, EscalationSocket>>(new Map());
|
|
315
|
+
|
|
316
|
+
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
|
317
|
+
|
|
318
|
+
const clearSupport = useCallback((ticketId?: string) => {
|
|
319
|
+
if (ticketId) {
|
|
320
|
+
// Remove specific ticket + its cached socket
|
|
321
|
+
const cached = pendingSocketsRef.current.get(ticketId);
|
|
322
|
+
if (cached) { cached.disconnect(); pendingSocketsRef.current.delete(ticketId); }
|
|
323
|
+
setTickets(prev => prev.filter(t => t.id !== ticketId));
|
|
324
|
+
setUnreadCounts(prev => { const n = { ...prev }; delete n[ticketId]; return n; });
|
|
325
|
+
if (selectedTicketId === ticketId) {
|
|
326
|
+
supportSocket?.disconnect();
|
|
327
|
+
setSupportSocket(null);
|
|
328
|
+
setSelectedTicketId(null);
|
|
329
|
+
setIsLiveAgentTyping(false);
|
|
330
|
+
setMessages([]);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Clear all — disconnect every cached socket
|
|
334
|
+
pendingSocketsRef.current.forEach(s => s.disconnect());
|
|
335
|
+
pendingSocketsRef.current.clear();
|
|
336
|
+
supportSocket?.disconnect();
|
|
337
|
+
setSupportSocket(null);
|
|
338
|
+
setSelectedTicketId(null);
|
|
339
|
+
setTickets([]);
|
|
340
|
+
setUnreadCounts({});
|
|
341
|
+
setIsLiveAgentTyping(false);
|
|
342
|
+
setMode('text');
|
|
343
|
+
}
|
|
344
|
+
}, [supportSocket, selectedTicketId]);
|
|
256
345
|
|
|
257
346
|
const clearMessages = useCallback(() => {
|
|
258
347
|
setMessages([]);
|
|
@@ -276,21 +365,352 @@ export function AIAgent({
|
|
|
276
365
|
}),
|
|
277
366
|
getHistory: () =>
|
|
278
367
|
messages.map((m) => ({ role: m.role, content: m.content })),
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
368
|
+
userContext,
|
|
369
|
+
pushToken,
|
|
370
|
+
pushTokenType,
|
|
371
|
+
onEscalationStarted: (tid, socket) => {
|
|
372
|
+
logger.info('AIAgent', '★★★ onEscalationStarted FIRED — ticketId:', tid);
|
|
373
|
+
// Cache the live socket so handleTicketSelect can reuse it without reconnecting
|
|
374
|
+
pendingSocketsRef.current.set(tid, socket);
|
|
375
|
+
setTickets(prev => {
|
|
376
|
+
if (prev.find(t => t.id === tid)) {
|
|
377
|
+
logger.info('AIAgent', '★★★ Ticket already in list, skipping add');
|
|
378
|
+
return prev;
|
|
379
|
+
}
|
|
380
|
+
const newList = [{ id: tid, reason: 'New escalation', screen: 'unknown', status: 'open', history: [], createdAt: new Date().toISOString(), wsUrl: '' }, ...prev];
|
|
381
|
+
logger.info('AIAgent', '★★★ Tickets updated, new length:', newList.length);
|
|
382
|
+
return newList;
|
|
383
|
+
});
|
|
384
|
+
// Switch to human mode so the ticket LIST is visible — do NOT auto-select
|
|
385
|
+
setMode('human');
|
|
386
|
+
setAutoExpandTrigger(prev => {
|
|
387
|
+
const next = prev + 1;
|
|
388
|
+
logger.info('AIAgent', '★★★ autoExpandTrigger:', prev, '→', next);
|
|
389
|
+
return next;
|
|
390
|
+
});
|
|
391
|
+
logger.info('AIAgent', '★★★ setMode("human") called from onEscalationStarted');
|
|
392
|
+
},
|
|
393
|
+
onHumanReply: (reply: string, ticketId?: string) => {
|
|
394
|
+
if (ticketId) {
|
|
395
|
+
// Always update the ticket's history (source of truth for ticket cards)
|
|
396
|
+
setTickets(prev => prev.map(t => {
|
|
397
|
+
if (t.id !== ticketId) return t;
|
|
398
|
+
return {
|
|
399
|
+
...t,
|
|
400
|
+
history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
|
|
401
|
+
};
|
|
402
|
+
}));
|
|
403
|
+
|
|
404
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
405
|
+
if (selectedTicketIdRef.current === ticketId) {
|
|
406
|
+
const humanMsg: AIMessage = {
|
|
407
|
+
id: `human-${Date.now()}`,
|
|
408
|
+
role: 'live_agent' as any,
|
|
409
|
+
content: reply,
|
|
410
|
+
timestamp: Date.now(),
|
|
411
|
+
};
|
|
412
|
+
setMessages((prev) => [...prev, humanMsg]);
|
|
413
|
+
setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
|
|
414
|
+
} else {
|
|
415
|
+
// Not viewing this ticket — increment unread badge
|
|
416
|
+
setUnreadCounts(prev => ({
|
|
417
|
+
...prev,
|
|
418
|
+
[ticketId]: (prev[ticketId] || 0) + 1,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
onTypingChange: (isTyping: boolean) => {
|
|
424
|
+
setIsLiveAgentTyping(isTyping);
|
|
425
|
+
},
|
|
426
|
+
onTicketClosed: (ticketId?: string) => {
|
|
427
|
+
logger.info('AIAgent', 'Ticket closed by agent — removing from list');
|
|
428
|
+
if (ticketId) {
|
|
429
|
+
setUnreadCounts(prev => {
|
|
430
|
+
const next = { ...prev };
|
|
431
|
+
delete next[ticketId];
|
|
432
|
+
return next;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
clearSupport(selectedTicketId ?? undefined);
|
|
289
436
|
},
|
|
290
437
|
});
|
|
291
438
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
292
439
|
}, [analyticsKey, navRef, customTools]);
|
|
293
440
|
|
|
441
|
+
// ─── Restore pending tickets on app start ──────────────────────
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
if (!analyticsKey) return;
|
|
444
|
+
|
|
445
|
+
void (async () => {
|
|
446
|
+
try {
|
|
447
|
+
// Wait for the device ID to be initialised before reading it.
|
|
448
|
+
// getDeviceId() is synchronous but returns null on cold start until
|
|
449
|
+
// initDeviceId() resolves — awaiting here prevents an early bail-out
|
|
450
|
+
// that would leave the Human tab hidden after an app refresh.
|
|
451
|
+
await initDeviceId();
|
|
452
|
+
const deviceId = getDeviceId();
|
|
453
|
+
|
|
454
|
+
logger.info('AIAgent', '★ Restore check — analyticsKey:', !!analyticsKey, 'userId:', userContext?.userId, 'pushToken:', !!pushToken, 'deviceId:', deviceId);
|
|
455
|
+
if (!userContext?.userId && !pushToken && !deviceId) return;
|
|
456
|
+
|
|
457
|
+
const query = new URLSearchParams({ analyticsKey });
|
|
458
|
+
if (userContext?.userId) query.append('userId', userContext.userId);
|
|
459
|
+
if (pushToken) query.append('pushToken', pushToken);
|
|
460
|
+
if (deviceId) query.append('deviceId', deviceId);
|
|
461
|
+
|
|
462
|
+
const url = `${ENDPOINTS.escalation}/api/v1/escalations/mine?${query.toString()}`;
|
|
463
|
+
logger.info('AIAgent', '★ Restore — fetching:', url);
|
|
464
|
+
const res = await fetch(url);
|
|
465
|
+
|
|
466
|
+
logger.info('AIAgent', '★ Restore — response status:', res.status);
|
|
467
|
+
if (!res.ok) return;
|
|
468
|
+
|
|
469
|
+
const data = await res.json();
|
|
470
|
+
const fetchedTickets: import('../support/types').SupportTicket[] = data.tickets ?? [];
|
|
471
|
+
logger.info('AIAgent', '★ Restore — found', fetchedTickets.length, 'active tickets');
|
|
472
|
+
|
|
473
|
+
if (fetchedTickets.length === 0) return;
|
|
474
|
+
|
|
475
|
+
// Initialize unread counts from backend (set together with tickets for instant badge)
|
|
476
|
+
const initialUnreadCounts: Record<string, number> = {};
|
|
477
|
+
for (const ticket of fetchedTickets) {
|
|
478
|
+
if (ticket.unreadCount && ticket.unreadCount > 0) {
|
|
479
|
+
initialUnreadCounts[ticket.id] = ticket.unreadCount;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
setTickets(fetchedTickets);
|
|
483
|
+
setUnreadCounts(initialUnreadCounts);
|
|
484
|
+
|
|
485
|
+
// Show the ticket list without auto-selecting — user taps in (Intercom-style).
|
|
486
|
+
// setMode switches the widget to human mode so the list is immediately visible.
|
|
487
|
+
setMode('human');
|
|
488
|
+
setAutoExpandTrigger(prev => prev + 1);
|
|
489
|
+
|
|
490
|
+
// If there is exactly one ticket, pre-wire its WebSocket so it is ready
|
|
491
|
+
// the moment the user taps the card (no extra connect delay).
|
|
492
|
+
if (fetchedTickets.length === 1) {
|
|
493
|
+
const ticket = fetchedTickets[0]!;
|
|
494
|
+
|
|
495
|
+
if (ticket.history?.length) {
|
|
496
|
+
const restored: AIMessage[] = ticket.history.map(
|
|
497
|
+
(entry: { role: string; content: string; timestamp?: string }, i: number) => ({
|
|
498
|
+
id: `restored-${ticket.id}-${i}`,
|
|
499
|
+
role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
|
|
500
|
+
content: entry.content,
|
|
501
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
setMessages(restored);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const socket = new EscalationSocket({
|
|
508
|
+
onReply: (reply: string) => {
|
|
509
|
+
const tid = ticket.id;
|
|
510
|
+
// Always update ticket history
|
|
511
|
+
setTickets(prev => prev.map(t => {
|
|
512
|
+
if (t.id !== tid) return t;
|
|
513
|
+
return {
|
|
514
|
+
...t,
|
|
515
|
+
history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
|
|
516
|
+
};
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
520
|
+
if (selectedTicketIdRef.current === tid) {
|
|
521
|
+
const msg: AIMessage = {
|
|
522
|
+
id: `human-${Date.now()}`,
|
|
523
|
+
role: 'assistant',
|
|
524
|
+
content: reply,
|
|
525
|
+
timestamp: Date.now(),
|
|
526
|
+
};
|
|
527
|
+
setMessages((prev) => [...prev, msg]);
|
|
528
|
+
setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
|
|
529
|
+
} else {
|
|
530
|
+
setUnreadCounts(prev => ({
|
|
531
|
+
...prev,
|
|
532
|
+
[tid]: (prev[tid] || 0) + 1,
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
onTypingChange: setIsLiveAgentTyping,
|
|
537
|
+
onTicketClosed: () => clearSupport(ticket.id),
|
|
538
|
+
onError: (err) => logger.error('AIAgent', '★ Restored socket error:', err),
|
|
539
|
+
});
|
|
540
|
+
socket.connect(ticket.wsUrl);
|
|
541
|
+
// Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
|
|
542
|
+
pendingSocketsRef.current.set(ticket.id, socket);
|
|
543
|
+
logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
|
|
544
|
+
}
|
|
545
|
+
} catch (err) {
|
|
546
|
+
logger.error('AIAgent', '★ Failed to restore tickets:', err);
|
|
547
|
+
}
|
|
548
|
+
})();
|
|
549
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
550
|
+
}, [analyticsKey]);
|
|
551
|
+
|
|
552
|
+
// ─── Ticket selection handlers ────────────────────────────────
|
|
553
|
+
const handleTicketSelect = useCallback(async (ticketId: string) => {
|
|
554
|
+
const ticket = tickets.find(t => t.id === ticketId);
|
|
555
|
+
if (!ticket) return;
|
|
556
|
+
|
|
557
|
+
// Cache (not disconnect!) the previous ticket's socket so it keeps
|
|
558
|
+
// receiving messages in the background and can update unread counts.
|
|
559
|
+
if (supportSocket && selectedTicketId && selectedTicketId !== ticketId) {
|
|
560
|
+
pendingSocketsRef.current.set(selectedTicketId, supportSocket);
|
|
561
|
+
setSupportSocket(null);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
setSelectedTicketId(ticketId);
|
|
565
|
+
setMode('human');
|
|
566
|
+
|
|
567
|
+
// Clear unread count when user opens a ticket
|
|
568
|
+
setUnreadCounts(prev => {
|
|
569
|
+
if (!prev[ticketId]) return prev;
|
|
570
|
+
const next = { ...prev };
|
|
571
|
+
delete next[ticketId];
|
|
572
|
+
return next;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Mark ticket as read on backend (source of truth)
|
|
576
|
+
(async () => {
|
|
577
|
+
try {
|
|
578
|
+
await fetch(
|
|
579
|
+
`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}/read?analyticsKey=${analyticsKey}`,
|
|
580
|
+
{ method: 'POST' }
|
|
581
|
+
);
|
|
582
|
+
logger.info('AIAgent', '★ Marked ticket as read:', ticketId);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
logger.warn('AIAgent', '★ Failed to mark ticket as read:', err);
|
|
585
|
+
}
|
|
586
|
+
})();
|
|
587
|
+
|
|
588
|
+
// Trigger scroll to bottom when modal opens
|
|
589
|
+
setChatScrollTrigger(prev => prev + 1);
|
|
590
|
+
|
|
591
|
+
// Fetch latest history from server — this is the source of truth and catches
|
|
592
|
+
// any messages that arrived while the socket was disconnected (modal closed,
|
|
593
|
+
// app backgrounded, etc.)
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch(
|
|
596
|
+
`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`
|
|
597
|
+
);
|
|
598
|
+
if (res.ok) {
|
|
599
|
+
const data = await res.json();
|
|
600
|
+
const history: Array<{ role: string; content: string; timestamp?: string }> =
|
|
601
|
+
Array.isArray(data.history) ? data.history : [];
|
|
602
|
+
const restored: AIMessage[] = history.map((entry, i) => ({
|
|
603
|
+
id: `restored-${ticketId}-${i}`,
|
|
604
|
+
role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
|
|
605
|
+
content: entry.content,
|
|
606
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
|
|
607
|
+
}));
|
|
608
|
+
setMessages(restored);
|
|
609
|
+
// Update ticket in local list with fresh history
|
|
610
|
+
if (data.wsUrl) {
|
|
611
|
+
setTickets(prev => prev.map(t => t.id === ticketId ? { ...t, history, wsUrl: data.wsUrl } : t));
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
// Fallback to local ticket history
|
|
615
|
+
if (ticket.history?.length) {
|
|
616
|
+
const restored: AIMessage[] = ticket.history.map(
|
|
617
|
+
(entry: { role: string; content: string; timestamp?: string }, i: number) => ({
|
|
618
|
+
id: `restored-${ticketId}-${i}`,
|
|
619
|
+
role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
|
|
620
|
+
content: entry.content,
|
|
621
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
|
|
622
|
+
})
|
|
623
|
+
);
|
|
624
|
+
setMessages(restored);
|
|
625
|
+
} else {
|
|
626
|
+
setMessages([]);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
logger.warn('AIAgent', '★ Failed to fetch ticket history, using local:', err);
|
|
631
|
+
if (ticket.history?.length) {
|
|
632
|
+
const restored: AIMessage[] = ticket.history.map(
|
|
633
|
+
(entry: { role: string; content: string; timestamp?: string }, i: number) => ({
|
|
634
|
+
id: `restored-${ticketId}-${i}`,
|
|
635
|
+
role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
|
|
636
|
+
content: entry.content,
|
|
637
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
|
|
638
|
+
})
|
|
639
|
+
);
|
|
640
|
+
setMessages(restored);
|
|
641
|
+
} else {
|
|
642
|
+
setMessages([]);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Reuse the already-connected socket if escalation just happened,
|
|
647
|
+
// otherwise create a fresh connection from the ticket's stored wsUrl.
|
|
648
|
+
const cached = pendingSocketsRef.current.get(ticketId);
|
|
649
|
+
if (cached) {
|
|
650
|
+
pendingSocketsRef.current.delete(ticketId);
|
|
651
|
+
setSupportSocket(cached);
|
|
652
|
+
logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const socket = new EscalationSocket({
|
|
657
|
+
onReply: (reply: string) => {
|
|
658
|
+
// Always update ticket history
|
|
659
|
+
setTickets(prev => prev.map(t => {
|
|
660
|
+
if (t.id !== ticketId) return t;
|
|
661
|
+
return {
|
|
662
|
+
...t,
|
|
663
|
+
history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
|
|
664
|
+
};
|
|
665
|
+
}));
|
|
666
|
+
|
|
667
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
668
|
+
if (selectedTicketIdRef.current === ticketId) {
|
|
669
|
+
const msg: AIMessage = {
|
|
670
|
+
id: `human-${Date.now()}`,
|
|
671
|
+
role: 'assistant',
|
|
672
|
+
content: reply,
|
|
673
|
+
timestamp: Date.now(),
|
|
674
|
+
};
|
|
675
|
+
setMessages(prev => [...prev, msg]);
|
|
676
|
+
setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
|
|
677
|
+
} else {
|
|
678
|
+
setUnreadCounts(prev => ({
|
|
679
|
+
...prev,
|
|
680
|
+
[ticketId]: (prev[ticketId] || 0) + 1,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
onTypingChange: setIsLiveAgentTyping,
|
|
685
|
+
onTicketClosed: (closedTicketId?: string) => {
|
|
686
|
+
if (closedTicketId) {
|
|
687
|
+
setUnreadCounts(prev => {
|
|
688
|
+
const next = { ...prev };
|
|
689
|
+
delete next[closedTicketId];
|
|
690
|
+
return next;
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
clearSupport(ticketId);
|
|
694
|
+
},
|
|
695
|
+
onError: (err) => logger.error('AIAgent', '★ Socket error on select:', err),
|
|
696
|
+
});
|
|
697
|
+
socket.connect(ticket.wsUrl);
|
|
698
|
+
setSupportSocket(socket);
|
|
699
|
+
}, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
|
|
700
|
+
|
|
701
|
+
const handleBackToTickets = useCallback(() => {
|
|
702
|
+
// Cache socket in pendingSocketsRef instead of disconnecting —
|
|
703
|
+
// keeps the WS alive so new messages update unreadCounts in real time.
|
|
704
|
+
if (supportSocket && selectedTicketId) {
|
|
705
|
+
pendingSocketsRef.current.set(selectedTicketId, supportSocket);
|
|
706
|
+
logger.info('AIAgent', '★ Socket cached for ticket:', selectedTicketId, '— stays alive for badge updates');
|
|
707
|
+
}
|
|
708
|
+
setSupportSocket(null);
|
|
709
|
+
setSelectedTicketId(null);
|
|
710
|
+
setMessages([]);
|
|
711
|
+
setIsLiveAgentTyping(false);
|
|
712
|
+
}, [supportSocket, selectedTicketId]);
|
|
713
|
+
|
|
294
714
|
const mergedCustomTools = useMemo(() => {
|
|
295
715
|
if (!autoEscalateTool) return customTools;
|
|
296
716
|
return { escalate_to_human: autoEscalateTool, ...customTools };
|
|
@@ -312,13 +732,13 @@ export function AIAgent({
|
|
|
312
732
|
const screenPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
313
733
|
const lastAgentErrorRef = useRef<string | null>(null);
|
|
314
734
|
|
|
315
|
-
// Compute available modes from props
|
|
316
735
|
const availableModes: AgentMode[] = useMemo(() => {
|
|
317
736
|
const modes: AgentMode[] = ['text'];
|
|
318
737
|
if (enableVoice) modes.push('voice');
|
|
319
|
-
|
|
738
|
+
if (tickets.length > 0) modes.push('human');
|
|
739
|
+
logger.info('AIAgent', '★ availableModes recomputed:', modes, '| tickets:', tickets.length, '| ticketIds:', tickets.map(t => t.id));
|
|
320
740
|
return modes;
|
|
321
|
-
}, [enableVoice]);
|
|
741
|
+
}, [enableVoice, tickets]);
|
|
322
742
|
|
|
323
743
|
// Ref-based resolver for ask_user — stays alive across renders
|
|
324
744
|
const askUserResolverRef = useRef<((answer: string) => void) | null>(null);
|
|
@@ -405,21 +825,19 @@ export function AIAgent({
|
|
|
405
825
|
return;
|
|
406
826
|
}
|
|
407
827
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
analyticsProxyUrl,
|
|
411
|
-
analyticsProxyHeaders,
|
|
412
|
-
debug,
|
|
413
|
-
});
|
|
414
|
-
telemetryRef.current = telemetry;
|
|
415
|
-
bindTelemetryService(telemetry);
|
|
416
|
-
telemetry.start();
|
|
828
|
+
// Initialize persistent device ID before telemetry starts
|
|
829
|
+
initDeviceId().then(() => {
|
|
417
830
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
831
|
+
const telemetry = new TelemetryService({
|
|
832
|
+
analyticsKey,
|
|
833
|
+
analyticsProxyUrl,
|
|
834
|
+
analyticsProxyHeaders,
|
|
835
|
+
debug,
|
|
836
|
+
});
|
|
837
|
+
telemetryRef.current = telemetry;
|
|
838
|
+
bindTelemetryService(telemetry);
|
|
839
|
+
telemetry.start();
|
|
840
|
+
}); // initDeviceId
|
|
423
841
|
}, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, debug]);
|
|
424
842
|
|
|
425
843
|
// ─── Security warnings ──────────────────────────────────────
|
|
@@ -427,7 +845,7 @@ export function AIAgent({
|
|
|
427
845
|
useEffect(() => {
|
|
428
846
|
// @ts-ignore
|
|
429
847
|
if (typeof __DEV__ !== 'undefined' && !__DEV__ && apiKey && !proxyUrl) {
|
|
430
|
-
|
|
848
|
+
logger.warn(
|
|
431
849
|
'[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' +
|
|
432
850
|
'This exposes your LLM provider key in the app binary. ' +
|
|
433
851
|
'Use `apiProxyUrl` to route requests through your backend instead. ' +
|
|
@@ -501,10 +919,10 @@ export function AIAgent({
|
|
|
501
919
|
|
|
502
920
|
// ─── Voice/Live Service Initialization ──────────────────────
|
|
503
921
|
|
|
504
|
-
// Initialize voice services when mode changes to voice
|
|
922
|
+
// Initialize voice services when mode changes to voice
|
|
505
923
|
useEffect(() => {
|
|
506
|
-
if (mode
|
|
507
|
-
logger.info('AIAgent',
|
|
924
|
+
if (mode !== 'voice') {
|
|
925
|
+
logger.info('AIAgent', `Mode ${mode} — skipping voice service init`);
|
|
508
926
|
return;
|
|
509
927
|
}
|
|
510
928
|
|
|
@@ -801,8 +1219,34 @@ export function AIAgent({
|
|
|
801
1219
|
if (!message.trim() || isThinking) return;
|
|
802
1220
|
|
|
803
1221
|
logger.info('AIAgent', `User message: "${message}"`);
|
|
1222
|
+
setLastUserMessage(message.trim());
|
|
1223
|
+
|
|
1224
|
+
// Intercom-style transparent intercept:
|
|
1225
|
+
// If we're connected to a human agent, all text input goes directly to them.
|
|
1226
|
+
if (selectedTicketId && supportSocket) {
|
|
1227
|
+
if (supportSocket.sendText(message)) {
|
|
1228
|
+
setMessages((prev) => [
|
|
1229
|
+
...prev,
|
|
1230
|
+
{ id: `user-${Date.now()}`, role: 'user', content: message.trim(), timestamp: Date.now() },
|
|
1231
|
+
]);
|
|
1232
|
+
|
|
1233
|
+
setIsThinking(true);
|
|
1234
|
+
setStatusText('Sending to agent...');
|
|
1235
|
+
setTimeout(() => {
|
|
1236
|
+
setIsThinking(false);
|
|
1237
|
+
setStatusText('');
|
|
1238
|
+
}, 800);
|
|
1239
|
+
} else {
|
|
1240
|
+
setLastResult({
|
|
1241
|
+
success: false,
|
|
1242
|
+
message: 'Failed to send message to support agent. Connection lost.',
|
|
1243
|
+
steps: [],
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
804
1248
|
|
|
805
|
-
// Append user message
|
|
1249
|
+
// Append user message to AI thread
|
|
806
1250
|
setMessages((prev) => [
|
|
807
1251
|
...prev,
|
|
808
1252
|
{
|
|
@@ -859,9 +1303,16 @@ export function AIAgent({
|
|
|
859
1303
|
});
|
|
860
1304
|
}
|
|
861
1305
|
|
|
862
|
-
|
|
1306
|
+
logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
|
|
1307
|
+
logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
1308
|
+
|
|
1309
|
+
// Don't overwrite lastResult if escalation already switched us to human mode
|
|
1310
|
+
// (mode in this closure is stale — the actual mode may have changed during async execution)
|
|
1311
|
+
const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
|
|
1312
|
+
if (!stepsHadEscalation) {
|
|
1313
|
+
setLastResult(result);
|
|
1314
|
+
}
|
|
863
1315
|
|
|
864
|
-
// Append assistant message
|
|
865
1316
|
setMessages((prev) => [
|
|
866
1317
|
...prev,
|
|
867
1318
|
{
|
|
@@ -990,8 +1441,9 @@ export function AIAgent({
|
|
|
990
1441
|
onSend={handleSend}
|
|
991
1442
|
isThinking={isThinking}
|
|
992
1443
|
lastResult={lastResult}
|
|
1444
|
+
lastUserMessage={lastUserMessage}
|
|
993
1445
|
language={'en'}
|
|
994
|
-
onDismiss={() => setLastResult(null)}
|
|
1446
|
+
onDismiss={() => { setLastResult(null); setLastUserMessage(null); }}
|
|
995
1447
|
theme={accentColor || theme ? {
|
|
996
1448
|
...(accentColor ? { primaryColor: accentColor } : {}),
|
|
997
1449
|
...theme,
|
|
@@ -999,12 +1451,13 @@ export function AIAgent({
|
|
|
999
1451
|
availableModes={availableModes}
|
|
1000
1452
|
mode={mode}
|
|
1001
1453
|
onModeChange={(newMode) => {
|
|
1002
|
-
logger.info('AIAgent',
|
|
1454
|
+
logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
1003
1455
|
setMode(newMode);
|
|
1004
1456
|
}}
|
|
1005
1457
|
isMicActive={isMicActive}
|
|
1006
1458
|
isSpeakerMuted={isSpeakerMuted}
|
|
1007
1459
|
isAISpeaking={isAISpeaking}
|
|
1460
|
+
isAgentTyping={isLiveAgentTyping}
|
|
1008
1461
|
onStopSession={stopVoiceSession}
|
|
1009
1462
|
isVoiceConnected={isVoiceConnected}
|
|
1010
1463
|
onMicToggle={(active) => {
|
|
@@ -1033,9 +1486,27 @@ export function AIAgent({
|
|
|
1033
1486
|
audioOutputRef.current?.unmute();
|
|
1034
1487
|
}
|
|
1035
1488
|
}}
|
|
1489
|
+
tickets={tickets}
|
|
1490
|
+
selectedTicketId={selectedTicketId}
|
|
1491
|
+
onTicketSelect={handleTicketSelect}
|
|
1492
|
+
onBackToTickets={handleBackToTickets}
|
|
1493
|
+
autoExpandTrigger={autoExpandTrigger}
|
|
1494
|
+
unreadCounts={unreadCounts}
|
|
1495
|
+
totalUnread={totalUnread}
|
|
1036
1496
|
/>
|
|
1037
1497
|
</ProactiveHint>
|
|
1038
1498
|
)}
|
|
1499
|
+
|
|
1500
|
+
{/* Support chat modal — opens when user taps a ticket */}
|
|
1501
|
+
<SupportChatModal
|
|
1502
|
+
visible={mode === 'human' && !!selectedTicketId}
|
|
1503
|
+
messages={messages}
|
|
1504
|
+
onSend={handleSend}
|
|
1505
|
+
onClose={handleBackToTickets}
|
|
1506
|
+
isAgentTyping={isLiveAgentTyping}
|
|
1507
|
+
isThinking={isThinking}
|
|
1508
|
+
scrollToEndTrigger={chatScrollTrigger}
|
|
1509
|
+
/>
|
|
1039
1510
|
</View>
|
|
1040
1511
|
</View>
|
|
1041
1512
|
</AgentContext.Provider>
|