@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
package/README.md
CHANGED
|
@@ -198,6 +198,17 @@ Then rebuild: `npx expo prebuild && npx expo run:android` (or `run:ios`)
|
|
|
198
198
|
|
|
199
199
|
</details>
|
|
200
200
|
|
|
201
|
+
<details>
|
|
202
|
+
<summary><b>💬 Human Support</b> — persist tickets and restore them across sessions</summary>
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npx expo install @react-native-async-storage/async-storage
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Optional** but recommended when using human escalation support. Without it, support tickets are only visible during the current app session and won't be restored after the app restarts.
|
|
209
|
+
|
|
210
|
+
</details>
|
|
211
|
+
|
|
201
212
|
---
|
|
202
213
|
|
|
203
214
|
## 🚀 Quick Start
|
|
@@ -25,11 +25,15 @@ import { AudioInputService } from "../services/AudioInputService.js";
|
|
|
25
25
|
import { AudioOutputService } from "../services/AudioOutputService.js";
|
|
26
26
|
import { TelemetryService, bindTelemetryService } from "../services/telemetry/index.js";
|
|
27
27
|
import { extractTouchLabel, checkRageClick } from "../services/telemetry/TouchAutoCapture.js";
|
|
28
|
+
import { initDeviceId, getDeviceId } from "../services/telemetry/device.js";
|
|
28
29
|
import { AgentErrorBoundary } from "./AgentErrorBoundary.js";
|
|
29
30
|
import { HighlightOverlay } from "./HighlightOverlay.js";
|
|
30
31
|
import { IdleDetector } from "../core/IdleDetector.js";
|
|
31
32
|
import { ProactiveHint } from "./ProactiveHint.js";
|
|
32
33
|
import { createEscalateTool } from "../support/escalateTool.js";
|
|
34
|
+
import { EscalationSocket } from "../support/EscalationSocket.js";
|
|
35
|
+
import { SupportChatModal } from "../support/SupportChatModal.js";
|
|
36
|
+
import { ENDPOINTS } from "../config/endpoints.js";
|
|
33
37
|
|
|
34
38
|
// ─── Context ───────────────────────────────────────────────────
|
|
35
39
|
|
|
@@ -79,7 +83,10 @@ export function AIAgent({
|
|
|
79
83
|
analyticsKey,
|
|
80
84
|
analyticsProxyUrl,
|
|
81
85
|
analyticsProxyHeaders,
|
|
82
|
-
proactiveHelp
|
|
86
|
+
proactiveHelp,
|
|
87
|
+
userContext,
|
|
88
|
+
pushToken,
|
|
89
|
+
pushTokenType
|
|
83
90
|
}) {
|
|
84
91
|
// Configure logger based on debug prop
|
|
85
92
|
React.useEffect(() => {
|
|
@@ -92,7 +99,70 @@ export function AIAgent({
|
|
|
92
99
|
const [isThinking, setIsThinking] = useState(false);
|
|
93
100
|
const [statusText, setStatusText] = useState('');
|
|
94
101
|
const [lastResult, setLastResult] = useState(null);
|
|
102
|
+
const [lastUserMessage, setLastUserMessage] = useState(null);
|
|
95
103
|
const [messages, setMessages] = useState([]);
|
|
104
|
+
const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
|
|
105
|
+
|
|
106
|
+
// Increment scroll trigger when messages change to auto-scroll chat modal
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (messages.length > 0) {
|
|
109
|
+
setChatScrollTrigger(prev => prev + 1);
|
|
110
|
+
}
|
|
111
|
+
}, [messages.length]);
|
|
112
|
+
|
|
113
|
+
// ── Support Modal State ──
|
|
114
|
+
const [tickets, setTickets] = useState([]);
|
|
115
|
+
const [selectedTicketId, setSelectedTicketId] = useState(null);
|
|
116
|
+
const [supportSocket, setSupportSocket] = useState(null);
|
|
117
|
+
const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
|
|
118
|
+
const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
|
|
119
|
+
const [unreadCounts, setUnreadCounts] = useState({});
|
|
120
|
+
// Ref mirrors selectedTicketId — lets socket callbacks access current value
|
|
121
|
+
// without stale closures (sockets are long-lived, closures capture old state).
|
|
122
|
+
const selectedTicketIdRef = useRef(null);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
selectedTicketIdRef.current = selectedTicketId;
|
|
125
|
+
}, [selectedTicketId]);
|
|
126
|
+
// Cache of live sockets by ticketId — keeps sockets alive even when user
|
|
127
|
+
// navigates back to the ticket list, so new messages still trigger badge updates.
|
|
128
|
+
const pendingSocketsRef = useRef(new Map());
|
|
129
|
+
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
|
130
|
+
const clearSupport = useCallback(ticketId => {
|
|
131
|
+
if (ticketId) {
|
|
132
|
+
// Remove specific ticket + its cached socket
|
|
133
|
+
const cached = pendingSocketsRef.current.get(ticketId);
|
|
134
|
+
if (cached) {
|
|
135
|
+
cached.disconnect();
|
|
136
|
+
pendingSocketsRef.current.delete(ticketId);
|
|
137
|
+
}
|
|
138
|
+
setTickets(prev => prev.filter(t => t.id !== ticketId));
|
|
139
|
+
setUnreadCounts(prev => {
|
|
140
|
+
const n = {
|
|
141
|
+
...prev
|
|
142
|
+
};
|
|
143
|
+
delete n[ticketId];
|
|
144
|
+
return n;
|
|
145
|
+
});
|
|
146
|
+
if (selectedTicketId === ticketId) {
|
|
147
|
+
supportSocket?.disconnect();
|
|
148
|
+
setSupportSocket(null);
|
|
149
|
+
setSelectedTicketId(null);
|
|
150
|
+
setIsLiveAgentTyping(false);
|
|
151
|
+
setMessages([]);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Clear all — disconnect every cached socket
|
|
155
|
+
pendingSocketsRef.current.forEach(s => s.disconnect());
|
|
156
|
+
pendingSocketsRef.current.clear();
|
|
157
|
+
supportSocket?.disconnect();
|
|
158
|
+
setSupportSocket(null);
|
|
159
|
+
setSelectedTicketId(null);
|
|
160
|
+
setTickets([]);
|
|
161
|
+
setUnreadCounts({});
|
|
162
|
+
setIsLiveAgentTyping(false);
|
|
163
|
+
setMode('text');
|
|
164
|
+
}
|
|
165
|
+
}, [supportSocket, selectedTicketId]);
|
|
96
166
|
const clearMessages = useCallback(() => {
|
|
97
167
|
setMessages([]);
|
|
98
168
|
setLastResult(null);
|
|
@@ -119,17 +189,373 @@ export function AIAgent({
|
|
|
119
189
|
role: m.role,
|
|
120
190
|
content: m.content
|
|
121
191
|
})),
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
192
|
+
userContext,
|
|
193
|
+
pushToken,
|
|
194
|
+
pushTokenType,
|
|
195
|
+
onEscalationStarted: (tid, socket) => {
|
|
196
|
+
logger.info('AIAgent', '★★★ onEscalationStarted FIRED — ticketId:', tid);
|
|
197
|
+
// Cache the live socket so handleTicketSelect can reuse it without reconnecting
|
|
198
|
+
pendingSocketsRef.current.set(tid, socket);
|
|
199
|
+
setTickets(prev => {
|
|
200
|
+
if (prev.find(t => t.id === tid)) {
|
|
201
|
+
logger.info('AIAgent', '★★★ Ticket already in list, skipping add');
|
|
202
|
+
return prev;
|
|
203
|
+
}
|
|
204
|
+
const newList = [{
|
|
205
|
+
id: tid,
|
|
206
|
+
reason: 'New escalation',
|
|
207
|
+
screen: 'unknown',
|
|
208
|
+
status: 'open',
|
|
209
|
+
history: [],
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
wsUrl: ''
|
|
212
|
+
}, ...prev];
|
|
213
|
+
logger.info('AIAgent', '★★★ Tickets updated, new length:', newList.length);
|
|
214
|
+
return newList;
|
|
215
|
+
});
|
|
216
|
+
// Switch to human mode so the ticket LIST is visible — do NOT auto-select
|
|
217
|
+
setMode('human');
|
|
218
|
+
setAutoExpandTrigger(prev => {
|
|
219
|
+
const next = prev + 1;
|
|
220
|
+
logger.info('AIAgent', '★★★ autoExpandTrigger:', prev, '→', next);
|
|
221
|
+
return next;
|
|
222
|
+
});
|
|
223
|
+
logger.info('AIAgent', '★★★ setMode("human") called from onEscalationStarted');
|
|
224
|
+
},
|
|
225
|
+
onHumanReply: (reply, ticketId) => {
|
|
226
|
+
if (ticketId) {
|
|
227
|
+
// Always update the ticket's history (source of truth for ticket cards)
|
|
228
|
+
setTickets(prev => prev.map(t => {
|
|
229
|
+
if (t.id !== ticketId) return t;
|
|
230
|
+
return {
|
|
231
|
+
...t,
|
|
232
|
+
history: [...(t.history || []), {
|
|
233
|
+
role: 'live_agent',
|
|
234
|
+
content: reply,
|
|
235
|
+
timestamp: new Date().toISOString()
|
|
236
|
+
}]
|
|
237
|
+
};
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
241
|
+
if (selectedTicketIdRef.current === ticketId) {
|
|
242
|
+
const humanMsg = {
|
|
243
|
+
id: `human-${Date.now()}`,
|
|
244
|
+
role: 'live_agent',
|
|
245
|
+
content: reply,
|
|
246
|
+
timestamp: Date.now()
|
|
247
|
+
};
|
|
248
|
+
setMessages(prev => [...prev, humanMsg]);
|
|
249
|
+
setLastResult({
|
|
250
|
+
success: true,
|
|
251
|
+
message: `👤 ${reply}`,
|
|
252
|
+
steps: []
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
// Not viewing this ticket — increment unread badge
|
|
256
|
+
setUnreadCounts(prev => ({
|
|
257
|
+
...prev,
|
|
258
|
+
[ticketId]: (prev[ticketId] || 0) + 1
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
onTypingChange: isTyping => {
|
|
264
|
+
setIsLiveAgentTyping(isTyping);
|
|
265
|
+
},
|
|
266
|
+
onTicketClosed: ticketId => {
|
|
267
|
+
logger.info('AIAgent', 'Ticket closed by agent — removing from list');
|
|
268
|
+
if (ticketId) {
|
|
269
|
+
setUnreadCounts(prev => {
|
|
270
|
+
const next = {
|
|
271
|
+
...prev
|
|
272
|
+
};
|
|
273
|
+
delete next[ticketId];
|
|
274
|
+
return next;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
clearSupport(selectedTicketId ?? undefined);
|
|
129
278
|
}
|
|
130
279
|
});
|
|
131
280
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
281
|
}, [analyticsKey, navRef, customTools]);
|
|
282
|
+
|
|
283
|
+
// ─── Restore pending tickets on app start ──────────────────────
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (!analyticsKey) return;
|
|
286
|
+
void (async () => {
|
|
287
|
+
try {
|
|
288
|
+
// Wait for the device ID to be initialised before reading it.
|
|
289
|
+
// getDeviceId() is synchronous but returns null on cold start until
|
|
290
|
+
// initDeviceId() resolves — awaiting here prevents an early bail-out
|
|
291
|
+
// that would leave the Human tab hidden after an app refresh.
|
|
292
|
+
await initDeviceId();
|
|
293
|
+
const deviceId = getDeviceId();
|
|
294
|
+
logger.info('AIAgent', '★ Restore check — analyticsKey:', !!analyticsKey, 'userId:', userContext?.userId, 'pushToken:', !!pushToken, 'deviceId:', deviceId);
|
|
295
|
+
if (!userContext?.userId && !pushToken && !deviceId) return;
|
|
296
|
+
const query = new URLSearchParams({
|
|
297
|
+
analyticsKey
|
|
298
|
+
});
|
|
299
|
+
if (userContext?.userId) query.append('userId', userContext.userId);
|
|
300
|
+
if (pushToken) query.append('pushToken', pushToken);
|
|
301
|
+
if (deviceId) query.append('deviceId', deviceId);
|
|
302
|
+
const url = `${ENDPOINTS.escalation}/api/v1/escalations/mine?${query.toString()}`;
|
|
303
|
+
logger.info('AIAgent', '★ Restore — fetching:', url);
|
|
304
|
+
const res = await fetch(url);
|
|
305
|
+
logger.info('AIAgent', '★ Restore — response status:', res.status);
|
|
306
|
+
if (!res.ok) return;
|
|
307
|
+
const data = await res.json();
|
|
308
|
+
const fetchedTickets = data.tickets ?? [];
|
|
309
|
+
logger.info('AIAgent', '★ Restore — found', fetchedTickets.length, 'active tickets');
|
|
310
|
+
if (fetchedTickets.length === 0) return;
|
|
311
|
+
|
|
312
|
+
// Initialize unread counts from backend (set together with tickets for instant badge)
|
|
313
|
+
const initialUnreadCounts = {};
|
|
314
|
+
for (const ticket of fetchedTickets) {
|
|
315
|
+
if (ticket.unreadCount && ticket.unreadCount > 0) {
|
|
316
|
+
initialUnreadCounts[ticket.id] = ticket.unreadCount;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
setTickets(fetchedTickets);
|
|
320
|
+
setUnreadCounts(initialUnreadCounts);
|
|
321
|
+
|
|
322
|
+
// Show the ticket list without auto-selecting — user taps in (Intercom-style).
|
|
323
|
+
// setMode switches the widget to human mode so the list is immediately visible.
|
|
324
|
+
setMode('human');
|
|
325
|
+
setAutoExpandTrigger(prev => prev + 1);
|
|
326
|
+
|
|
327
|
+
// If there is exactly one ticket, pre-wire its WebSocket so it is ready
|
|
328
|
+
// the moment the user taps the card (no extra connect delay).
|
|
329
|
+
if (fetchedTickets.length === 1) {
|
|
330
|
+
const ticket = fetchedTickets[0];
|
|
331
|
+
if (ticket.history?.length) {
|
|
332
|
+
const restored = ticket.history.map((entry, i) => ({
|
|
333
|
+
id: `restored-${ticket.id}-${i}`,
|
|
334
|
+
role: entry.role === 'live_agent' ? 'assistant' : entry.role,
|
|
335
|
+
content: entry.content,
|
|
336
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
337
|
+
}));
|
|
338
|
+
setMessages(restored);
|
|
339
|
+
}
|
|
340
|
+
const socket = new EscalationSocket({
|
|
341
|
+
onReply: reply => {
|
|
342
|
+
const tid = ticket.id;
|
|
343
|
+
// Always update ticket history
|
|
344
|
+
setTickets(prev => prev.map(t => {
|
|
345
|
+
if (t.id !== tid) return t;
|
|
346
|
+
return {
|
|
347
|
+
...t,
|
|
348
|
+
history: [...(t.history || []), {
|
|
349
|
+
role: 'live_agent',
|
|
350
|
+
content: reply,
|
|
351
|
+
timestamp: new Date().toISOString()
|
|
352
|
+
}]
|
|
353
|
+
};
|
|
354
|
+
}));
|
|
355
|
+
|
|
356
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
357
|
+
if (selectedTicketIdRef.current === tid) {
|
|
358
|
+
const msg = {
|
|
359
|
+
id: `human-${Date.now()}`,
|
|
360
|
+
role: 'assistant',
|
|
361
|
+
content: reply,
|
|
362
|
+
timestamp: Date.now()
|
|
363
|
+
};
|
|
364
|
+
setMessages(prev => [...prev, msg]);
|
|
365
|
+
setLastResult({
|
|
366
|
+
success: true,
|
|
367
|
+
message: `👤 ${reply}`,
|
|
368
|
+
steps: []
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
setUnreadCounts(prev => ({
|
|
372
|
+
...prev,
|
|
373
|
+
[tid]: (prev[tid] || 0) + 1
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
onTypingChange: setIsLiveAgentTyping,
|
|
378
|
+
onTicketClosed: () => clearSupport(ticket.id),
|
|
379
|
+
onError: err => logger.error('AIAgent', '★ Restored socket error:', err)
|
|
380
|
+
});
|
|
381
|
+
socket.connect(ticket.wsUrl);
|
|
382
|
+
// Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
|
|
383
|
+
pendingSocketsRef.current.set(ticket.id, socket);
|
|
384
|
+
logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
|
|
385
|
+
}
|
|
386
|
+
} catch (err) {
|
|
387
|
+
logger.error('AIAgent', '★ Failed to restore tickets:', err);
|
|
388
|
+
}
|
|
389
|
+
})();
|
|
390
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
391
|
+
}, [analyticsKey]);
|
|
392
|
+
|
|
393
|
+
// ─── Ticket selection handlers ────────────────────────────────
|
|
394
|
+
const handleTicketSelect = useCallback(async ticketId => {
|
|
395
|
+
const ticket = tickets.find(t => t.id === ticketId);
|
|
396
|
+
if (!ticket) return;
|
|
397
|
+
|
|
398
|
+
// Cache (not disconnect!) the previous ticket's socket so it keeps
|
|
399
|
+
// receiving messages in the background and can update unread counts.
|
|
400
|
+
if (supportSocket && selectedTicketId && selectedTicketId !== ticketId) {
|
|
401
|
+
pendingSocketsRef.current.set(selectedTicketId, supportSocket);
|
|
402
|
+
setSupportSocket(null);
|
|
403
|
+
}
|
|
404
|
+
setSelectedTicketId(ticketId);
|
|
405
|
+
setMode('human');
|
|
406
|
+
|
|
407
|
+
// Clear unread count when user opens a ticket
|
|
408
|
+
setUnreadCounts(prev => {
|
|
409
|
+
if (!prev[ticketId]) return prev;
|
|
410
|
+
const next = {
|
|
411
|
+
...prev
|
|
412
|
+
};
|
|
413
|
+
delete next[ticketId];
|
|
414
|
+
return next;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Mark ticket as read on backend (source of truth)
|
|
418
|
+
(async () => {
|
|
419
|
+
try {
|
|
420
|
+
await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}/read?analyticsKey=${analyticsKey}`, {
|
|
421
|
+
method: 'POST'
|
|
422
|
+
});
|
|
423
|
+
logger.info('AIAgent', '★ Marked ticket as read:', ticketId);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
logger.warn('AIAgent', '★ Failed to mark ticket as read:', err);
|
|
426
|
+
}
|
|
427
|
+
})();
|
|
428
|
+
|
|
429
|
+
// Trigger scroll to bottom when modal opens
|
|
430
|
+
setChatScrollTrigger(prev => prev + 1);
|
|
431
|
+
|
|
432
|
+
// Fetch latest history from server — this is the source of truth and catches
|
|
433
|
+
// any messages that arrived while the socket was disconnected (modal closed,
|
|
434
|
+
// app backgrounded, etc.)
|
|
435
|
+
try {
|
|
436
|
+
const res = await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`);
|
|
437
|
+
if (res.ok) {
|
|
438
|
+
const data = await res.json();
|
|
439
|
+
const history = Array.isArray(data.history) ? data.history : [];
|
|
440
|
+
const restored = history.map((entry, i) => ({
|
|
441
|
+
id: `restored-${ticketId}-${i}`,
|
|
442
|
+
role: entry.role === 'live_agent' ? 'assistant' : entry.role,
|
|
443
|
+
content: entry.content,
|
|
444
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
445
|
+
}));
|
|
446
|
+
setMessages(restored);
|
|
447
|
+
// Update ticket in local list with fresh history
|
|
448
|
+
if (data.wsUrl) {
|
|
449
|
+
setTickets(prev => prev.map(t => t.id === ticketId ? {
|
|
450
|
+
...t,
|
|
451
|
+
history,
|
|
452
|
+
wsUrl: data.wsUrl
|
|
453
|
+
} : t));
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
// Fallback to local ticket history
|
|
457
|
+
if (ticket.history?.length) {
|
|
458
|
+
const restored = ticket.history.map((entry, i) => ({
|
|
459
|
+
id: `restored-${ticketId}-${i}`,
|
|
460
|
+
role: entry.role === 'live_agent' ? 'assistant' : entry.role,
|
|
461
|
+
content: entry.content,
|
|
462
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
463
|
+
}));
|
|
464
|
+
setMessages(restored);
|
|
465
|
+
} else {
|
|
466
|
+
setMessages([]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
logger.warn('AIAgent', '★ Failed to fetch ticket history, using local:', err);
|
|
471
|
+
if (ticket.history?.length) {
|
|
472
|
+
const restored = ticket.history.map((entry, i) => ({
|
|
473
|
+
id: `restored-${ticketId}-${i}`,
|
|
474
|
+
role: entry.role === 'live_agent' ? 'assistant' : entry.role,
|
|
475
|
+
content: entry.content,
|
|
476
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
477
|
+
}));
|
|
478
|
+
setMessages(restored);
|
|
479
|
+
} else {
|
|
480
|
+
setMessages([]);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Reuse the already-connected socket if escalation just happened,
|
|
485
|
+
// otherwise create a fresh connection from the ticket's stored wsUrl.
|
|
486
|
+
const cached = pendingSocketsRef.current.get(ticketId);
|
|
487
|
+
if (cached) {
|
|
488
|
+
pendingSocketsRef.current.delete(ticketId);
|
|
489
|
+
setSupportSocket(cached);
|
|
490
|
+
logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const socket = new EscalationSocket({
|
|
494
|
+
onReply: reply => {
|
|
495
|
+
// Always update ticket history
|
|
496
|
+
setTickets(prev => prev.map(t => {
|
|
497
|
+
if (t.id !== ticketId) return t;
|
|
498
|
+
return {
|
|
499
|
+
...t,
|
|
500
|
+
history: [...(t.history || []), {
|
|
501
|
+
role: 'live_agent',
|
|
502
|
+
content: reply,
|
|
503
|
+
timestamp: new Date().toISOString()
|
|
504
|
+
}]
|
|
505
|
+
};
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
// Route via ref: only push to messages[] if user is viewing THIS ticket
|
|
509
|
+
if (selectedTicketIdRef.current === ticketId) {
|
|
510
|
+
const msg = {
|
|
511
|
+
id: `human-${Date.now()}`,
|
|
512
|
+
role: 'assistant',
|
|
513
|
+
content: reply,
|
|
514
|
+
timestamp: Date.now()
|
|
515
|
+
};
|
|
516
|
+
setMessages(prev => [...prev, msg]);
|
|
517
|
+
setLastResult({
|
|
518
|
+
success: true,
|
|
519
|
+
message: `👤 ${reply}`,
|
|
520
|
+
steps: []
|
|
521
|
+
});
|
|
522
|
+
} else {
|
|
523
|
+
setUnreadCounts(prev => ({
|
|
524
|
+
...prev,
|
|
525
|
+
[ticketId]: (prev[ticketId] || 0) + 1
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
onTypingChange: setIsLiveAgentTyping,
|
|
530
|
+
onTicketClosed: closedTicketId => {
|
|
531
|
+
if (closedTicketId) {
|
|
532
|
+
setUnreadCounts(prev => {
|
|
533
|
+
const next = {
|
|
534
|
+
...prev
|
|
535
|
+
};
|
|
536
|
+
delete next[closedTicketId];
|
|
537
|
+
return next;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
clearSupport(ticketId);
|
|
541
|
+
},
|
|
542
|
+
onError: err => logger.error('AIAgent', '★ Socket error on select:', err)
|
|
543
|
+
});
|
|
544
|
+
socket.connect(ticket.wsUrl);
|
|
545
|
+
setSupportSocket(socket);
|
|
546
|
+
}, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
|
|
547
|
+
const handleBackToTickets = useCallback(() => {
|
|
548
|
+
// Cache socket in pendingSocketsRef instead of disconnecting —
|
|
549
|
+
// keeps the WS alive so new messages update unreadCounts in real time.
|
|
550
|
+
if (supportSocket && selectedTicketId) {
|
|
551
|
+
pendingSocketsRef.current.set(selectedTicketId, supportSocket);
|
|
552
|
+
logger.info('AIAgent', '★ Socket cached for ticket:', selectedTicketId, '— stays alive for badge updates');
|
|
553
|
+
}
|
|
554
|
+
setSupportSocket(null);
|
|
555
|
+
setSelectedTicketId(null);
|
|
556
|
+
setMessages([]);
|
|
557
|
+
setIsLiveAgentTyping(false);
|
|
558
|
+
}, [supportSocket, selectedTicketId]);
|
|
133
559
|
const mergedCustomTools = useMemo(() => {
|
|
134
560
|
if (!autoEscalateTool) return customTools;
|
|
135
561
|
return {
|
|
@@ -152,14 +578,13 @@ export function AIAgent({
|
|
|
152
578
|
const lastScreenContextRef = useRef('');
|
|
153
579
|
const screenPollIntervalRef = useRef(null);
|
|
154
580
|
const lastAgentErrorRef = useRef(null);
|
|
155
|
-
|
|
156
|
-
// Compute available modes from props
|
|
157
581
|
const availableModes = useMemo(() => {
|
|
158
582
|
const modes = ['text'];
|
|
159
583
|
if (enableVoice) modes.push('voice');
|
|
160
|
-
|
|
584
|
+
if (tickets.length > 0) modes.push('human');
|
|
585
|
+
logger.info('AIAgent', '★ availableModes recomputed:', modes, '| tickets:', tickets.length, '| ticketIds:', tickets.map(t => t.id));
|
|
161
586
|
return modes;
|
|
162
|
-
}, [enableVoice]);
|
|
587
|
+
}, [enableVoice, tickets]);
|
|
163
588
|
|
|
164
589
|
// Ref-based resolver for ask_user — stays alive across renders
|
|
165
590
|
const askUserResolverRef = useRef(null);
|
|
@@ -236,20 +661,19 @@ export function AIAgent({
|
|
|
236
661
|
bindTelemetryService(null);
|
|
237
662
|
return;
|
|
238
663
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
telemetry
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
};
|
|
664
|
+
|
|
665
|
+
// Initialize persistent device ID before telemetry starts
|
|
666
|
+
initDeviceId().then(() => {
|
|
667
|
+
const telemetry = new TelemetryService({
|
|
668
|
+
analyticsKey,
|
|
669
|
+
analyticsProxyUrl,
|
|
670
|
+
analyticsProxyHeaders,
|
|
671
|
+
debug
|
|
672
|
+
});
|
|
673
|
+
telemetryRef.current = telemetry;
|
|
674
|
+
bindTelemetryService(telemetry);
|
|
675
|
+
telemetry.start();
|
|
676
|
+
}); // initDeviceId
|
|
253
677
|
}, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, debug]);
|
|
254
678
|
|
|
255
679
|
// ─── Security warnings ──────────────────────────────────────
|
|
@@ -257,7 +681,7 @@ export function AIAgent({
|
|
|
257
681
|
useEffect(() => {
|
|
258
682
|
// @ts-ignore
|
|
259
683
|
if (typeof __DEV__ !== 'undefined' && !__DEV__ && apiKey && !proxyUrl) {
|
|
260
|
-
|
|
684
|
+
logger.warn('[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' + 'This exposes your LLM provider key in the app binary. ' + 'Use `apiProxyUrl` to route requests through your backend instead. ' + 'See docs for details.');
|
|
261
685
|
}
|
|
262
686
|
}, [apiKey, proxyUrl]);
|
|
263
687
|
|
|
@@ -318,10 +742,10 @@ export function AIAgent({
|
|
|
318
742
|
|
|
319
743
|
// ─── Voice/Live Service Initialization ──────────────────────
|
|
320
744
|
|
|
321
|
-
// Initialize voice services when mode changes to voice
|
|
745
|
+
// Initialize voice services when mode changes to voice
|
|
322
746
|
useEffect(() => {
|
|
323
|
-
if (mode
|
|
324
|
-
logger.info('AIAgent',
|
|
747
|
+
if (mode !== 'voice') {
|
|
748
|
+
logger.info('AIAgent', `Mode ${mode} — skipping voice service init`);
|
|
325
749
|
return;
|
|
326
750
|
}
|
|
327
751
|
logger.info('AIAgent', `Mode changed to "${mode}" — initializing voice services...`);
|
|
@@ -599,8 +1023,35 @@ export function AIAgent({
|
|
|
599
1023
|
const handleSend = useCallback(async (message, options) => {
|
|
600
1024
|
if (!message.trim() || isThinking) return;
|
|
601
1025
|
logger.info('AIAgent', `User message: "${message}"`);
|
|
1026
|
+
setLastUserMessage(message.trim());
|
|
602
1027
|
|
|
603
|
-
//
|
|
1028
|
+
// Intercom-style transparent intercept:
|
|
1029
|
+
// If we're connected to a human agent, all text input goes directly to them.
|
|
1030
|
+
if (selectedTicketId && supportSocket) {
|
|
1031
|
+
if (supportSocket.sendText(message)) {
|
|
1032
|
+
setMessages(prev => [...prev, {
|
|
1033
|
+
id: `user-${Date.now()}`,
|
|
1034
|
+
role: 'user',
|
|
1035
|
+
content: message.trim(),
|
|
1036
|
+
timestamp: Date.now()
|
|
1037
|
+
}]);
|
|
1038
|
+
setIsThinking(true);
|
|
1039
|
+
setStatusText('Sending to agent...');
|
|
1040
|
+
setTimeout(() => {
|
|
1041
|
+
setIsThinking(false);
|
|
1042
|
+
setStatusText('');
|
|
1043
|
+
}, 800);
|
|
1044
|
+
} else {
|
|
1045
|
+
setLastResult({
|
|
1046
|
+
success: false,
|
|
1047
|
+
message: 'Failed to send message to support agent. Connection lost.',
|
|
1048
|
+
steps: []
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Append user message to AI thread
|
|
604
1055
|
setMessages(prev => [...prev, {
|
|
605
1056
|
id: Date.now().toString() + Math.random(),
|
|
606
1057
|
role: 'user',
|
|
@@ -649,9 +1100,15 @@ export function AIAgent({
|
|
|
649
1100
|
cost: result.tokenUsage?.estimatedCostUSD ?? 0
|
|
650
1101
|
});
|
|
651
1102
|
}
|
|
652
|
-
|
|
1103
|
+
logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
|
|
1104
|
+
logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
653
1105
|
|
|
654
|
-
//
|
|
1106
|
+
// Don't overwrite lastResult if escalation already switched us to human mode
|
|
1107
|
+
// (mode in this closure is stale — the actual mode may have changed during async execution)
|
|
1108
|
+
const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
|
|
1109
|
+
if (!stepsHadEscalation) {
|
|
1110
|
+
setLastResult(result);
|
|
1111
|
+
}
|
|
655
1112
|
setMessages(prev => [...prev, {
|
|
656
1113
|
id: Date.now().toString() + Math.random(),
|
|
657
1114
|
role: 'assistant',
|
|
@@ -765,8 +1222,12 @@ export function AIAgent({
|
|
|
765
1222
|
onSend: handleSend,
|
|
766
1223
|
isThinking: isThinking,
|
|
767
1224
|
lastResult: lastResult,
|
|
1225
|
+
lastUserMessage: lastUserMessage,
|
|
768
1226
|
language: 'en',
|
|
769
|
-
onDismiss: () =>
|
|
1227
|
+
onDismiss: () => {
|
|
1228
|
+
setLastResult(null);
|
|
1229
|
+
setLastUserMessage(null);
|
|
1230
|
+
},
|
|
770
1231
|
theme: accentColor || theme ? {
|
|
771
1232
|
...(accentColor ? {
|
|
772
1233
|
primaryColor: accentColor
|
|
@@ -776,12 +1237,13 @@ export function AIAgent({
|
|
|
776
1237
|
availableModes: availableModes,
|
|
777
1238
|
mode: mode,
|
|
778
1239
|
onModeChange: newMode => {
|
|
779
|
-
logger.info('AIAgent',
|
|
1240
|
+
logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
780
1241
|
setMode(newMode);
|
|
781
1242
|
},
|
|
782
1243
|
isMicActive: isMicActive,
|
|
783
1244
|
isSpeakerMuted: isSpeakerMuted,
|
|
784
1245
|
isAISpeaking: isAISpeaking,
|
|
1246
|
+
isAgentTyping: isLiveAgentTyping,
|
|
785
1247
|
onStopSession: stopVoiceSession,
|
|
786
1248
|
isVoiceConnected: isVoiceConnected,
|
|
787
1249
|
onMicToggle: active => {
|
|
@@ -809,8 +1271,23 @@ export function AIAgent({
|
|
|
809
1271
|
} else {
|
|
810
1272
|
audioOutputRef.current?.unmute();
|
|
811
1273
|
}
|
|
812
|
-
}
|
|
1274
|
+
},
|
|
1275
|
+
tickets: tickets,
|
|
1276
|
+
selectedTicketId: selectedTicketId,
|
|
1277
|
+
onTicketSelect: handleTicketSelect,
|
|
1278
|
+
onBackToTickets: handleBackToTickets,
|
|
1279
|
+
autoExpandTrigger: autoExpandTrigger,
|
|
1280
|
+
unreadCounts: unreadCounts,
|
|
1281
|
+
totalUnread: totalUnread
|
|
813
1282
|
})
|
|
1283
|
+
}), /*#__PURE__*/_jsx(SupportChatModal, {
|
|
1284
|
+
visible: mode === 'human' && !!selectedTicketId,
|
|
1285
|
+
messages: messages,
|
|
1286
|
+
onSend: handleSend,
|
|
1287
|
+
onClose: handleBackToTickets,
|
|
1288
|
+
isAgentTyping: isLiveAgentTyping,
|
|
1289
|
+
isThinking: isThinking,
|
|
1290
|
+
scrollToEndTrigger: chatScrollTrigger
|
|
814
1291
|
})]
|
|
815
1292
|
})]
|
|
816
1293
|
})
|