@mobileai/react-native 0.9.11 → 0.9.12
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/lib/module/components/AIAgent.js +141 -22
- package/lib/module/components/AIAgent.js.map +1 -1
- package/lib/module/components/AgentChatBar.js +3 -14
- package/lib/module/components/AgentChatBar.js.map +1 -1
- package/lib/module/services/telemetry/TelemetryService.js +5 -2
- package/lib/module/services/telemetry/TelemetryService.js.map +1 -1
- package/lib/module/services/telemetry/deviceMetadata.js +10 -0
- package/lib/module/services/telemetry/deviceMetadata.js.map +1 -0
- package/lib/module/support/EscalationEventSource.js +168 -0
- package/lib/module/support/EscalationEventSource.js.map +1 -0
- package/lib/module/support/SupportChatModal.js +32 -4
- package/lib/module/support/SupportChatModal.js.map +1 -1
- package/lib/module/support/escalateTool.js +8 -2
- package/lib/module/support/escalateTool.js.map +1 -1
- package/lib/module/support/index.js +2 -0
- package/lib/module/support/index.js.map +1 -1
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
- package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +2 -1
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts +6 -0
- package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts.map +1 -0
- package/lib/typescript/src/support/EscalationEventSource.d.ts +38 -0
- package/lib/typescript/src/support/EscalationEventSource.d.ts.map +1 -0
- package/lib/typescript/src/support/SupportChatModal.d.ts +3 -1
- package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -1
- package/lib/typescript/src/support/escalateTool.d.ts +1 -0
- package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
- package/lib/typescript/src/support/index.d.ts +1 -0
- package/lib/typescript/src/support/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AIAgent.tsx +134 -21
- package/src/components/AgentChatBar.tsx +2 -9
- package/src/services/telemetry/TelemetryService.ts +6 -1
- package/src/services/telemetry/deviceMetadata.ts +13 -0
- package/src/support/EscalationEventSource.ts +190 -0
- package/src/support/SupportChatModal.tsx +58 -22
- package/src/support/escalateTool.ts +8 -2
- package/src/support/index.ts +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EscalationEventSource — SSE client using fetch + ReadableStream.
|
|
3
|
+
*
|
|
4
|
+
* Uses only the fetch API (available in all React Native runtimes)
|
|
5
|
+
* to consume Server-Sent Events — no EventSource polyfill needed.
|
|
6
|
+
* Provides a reliable, auto-reconnecting channel for server-push
|
|
7
|
+
* events like `ticket_closed` that complements the bidirectional
|
|
8
|
+
* WebSocket used for chat.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. SDK calls connect() → fetch with streaming response
|
|
12
|
+
* 2. Server holds connection open, pushes `ticket_closed` when agent resolves
|
|
13
|
+
* 3. On disconnect, auto-reconnects with exponential backoff (max 5 attempts)
|
|
14
|
+
* 4. If ticket is already closed, server responds immediately with the event
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { logger } from '../utils/logger';
|
|
18
|
+
|
|
19
|
+
export interface EscalationEventSourceOptions {
|
|
20
|
+
url: string;
|
|
21
|
+
onTicketClosed?: (ticketId: string) => void;
|
|
22
|
+
onConnected?: (ticketId: string) => void;
|
|
23
|
+
onError?: (error: Error) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class EscalationEventSource {
|
|
27
|
+
private abortController: AbortController | null = null;
|
|
28
|
+
private intentionalClose = false;
|
|
29
|
+
private reconnectAttempts = 0;
|
|
30
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
private readonly maxReconnectAttempts = 5;
|
|
32
|
+
private readonly options: EscalationEventSourceOptions;
|
|
33
|
+
|
|
34
|
+
constructor(options: EscalationEventSourceOptions) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
connect(): void {
|
|
39
|
+
this.intentionalClose = false;
|
|
40
|
+
this.reconnectAttempts = 0;
|
|
41
|
+
this.openConnection();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
disconnect(): void {
|
|
45
|
+
this.intentionalClose = true;
|
|
46
|
+
if (this.reconnectTimer) {
|
|
47
|
+
clearTimeout(this.reconnectTimer);
|
|
48
|
+
this.reconnectTimer = null;
|
|
49
|
+
}
|
|
50
|
+
if (this.abortController) {
|
|
51
|
+
this.abortController.abort();
|
|
52
|
+
this.abortController = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async openConnection(): Promise<void> {
|
|
57
|
+
if (this.intentionalClose) return;
|
|
58
|
+
|
|
59
|
+
this.abortController = new AbortController();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(this.options.url, {
|
|
63
|
+
signal: this.abortController.signal,
|
|
64
|
+
headers: { Accept: 'text/event-stream' },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
logger.warn('EscalationSSE', 'Non-OK response:', response.status);
|
|
69
|
+
this.scheduleReconnect();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.body) {
|
|
74
|
+
logger.warn('EscalationSSE', 'No readable body — falling back to reading full response');
|
|
75
|
+
await this.readFullResponse(response);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.reconnectAttempts = 0;
|
|
80
|
+
await this.readStream(response.body);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (this.intentionalClose) return;
|
|
83
|
+
if ((err as Error).name === 'AbortError') return;
|
|
84
|
+
logger.warn('EscalationSSE', 'Connection error:', (err as Error).message);
|
|
85
|
+
this.options.onError?.(err as Error);
|
|
86
|
+
this.scheduleReconnect();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async readStream(body: ReadableStream<Uint8Array>): Promise<void> {
|
|
91
|
+
const reader = body.getReader();
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let buffer = '';
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
while (true) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done) break;
|
|
99
|
+
|
|
100
|
+
buffer += decoder.decode(value, { stream: true });
|
|
101
|
+
const lines = buffer.split('\n');
|
|
102
|
+
buffer = lines.pop()!;
|
|
103
|
+
|
|
104
|
+
let currentEvent = '';
|
|
105
|
+
let currentData = '';
|
|
106
|
+
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (line.startsWith('event: ')) {
|
|
109
|
+
currentEvent = line.slice(7).trim();
|
|
110
|
+
} else if (line.startsWith('data: ')) {
|
|
111
|
+
currentData = line.slice(6).trim();
|
|
112
|
+
} else if (line === '' && currentEvent && currentData) {
|
|
113
|
+
this.handleEvent(currentEvent, currentData);
|
|
114
|
+
currentEvent = '';
|
|
115
|
+
currentData = '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (this.intentionalClose) return;
|
|
121
|
+
if ((err as Error).name === 'AbortError') return;
|
|
122
|
+
logger.warn('EscalationSSE', 'Stream read error:', (err as Error).message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!this.intentionalClose) {
|
|
126
|
+
this.scheduleReconnect();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async readFullResponse(response: Response): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
const text = await response.text();
|
|
133
|
+
let currentEvent = '';
|
|
134
|
+
let currentData = '';
|
|
135
|
+
|
|
136
|
+
for (const line of text.split('\n')) {
|
|
137
|
+
if (line.startsWith('event: ')) {
|
|
138
|
+
currentEvent = line.slice(7).trim();
|
|
139
|
+
} else if (line.startsWith('data: ')) {
|
|
140
|
+
currentData = line.slice(6).trim();
|
|
141
|
+
} else if (line === '' && currentEvent && currentData) {
|
|
142
|
+
this.handleEvent(currentEvent, currentData);
|
|
143
|
+
currentEvent = '';
|
|
144
|
+
currentData = '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (this.intentionalClose) return;
|
|
149
|
+
logger.warn('EscalationSSE', 'Full response read error:', (err as Error).message);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.intentionalClose) {
|
|
153
|
+
this.scheduleReconnect();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private handleEvent(event: string, data: string): void {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(data);
|
|
160
|
+
|
|
161
|
+
if (event === 'connected') {
|
|
162
|
+
logger.info('EscalationSSE', 'Connected for ticket:', parsed.ticketId);
|
|
163
|
+
this.options.onConnected?.(parsed.ticketId);
|
|
164
|
+
} else if (event === 'ticket_closed') {
|
|
165
|
+
logger.info('EscalationSSE', 'Ticket closed event:', parsed.ticketId);
|
|
166
|
+
this.options.onTicketClosed?.(parsed.ticketId);
|
|
167
|
+
this.intentionalClose = true;
|
|
168
|
+
this.abortController?.abort();
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore parse error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private scheduleReconnect(): void {
|
|
176
|
+
if (this.intentionalClose) return;
|
|
177
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
178
|
+
logger.warn('EscalationSSE', 'Max reconnect attempts reached — giving up');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 16_000);
|
|
183
|
+
this.reconnectAttempts++;
|
|
184
|
+
logger.info('EscalationSSE', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
185
|
+
|
|
186
|
+
this.reconnectTimer = setTimeout(() => {
|
|
187
|
+
this.openConnection();
|
|
188
|
+
}, delay);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -31,6 +31,8 @@ interface SupportChatModalProps {
|
|
|
31
31
|
isThinking?: boolean;
|
|
32
32
|
/** Optional: externally controlled scroll trigger. Pass when messages update externally. */
|
|
33
33
|
scrollToEndTrigger?: number;
|
|
34
|
+
/** Ticket status — when 'closed' or 'resolved', input is hidden and a banner is shown. */
|
|
35
|
+
ticketStatus?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
// ─── Helpers ───────────────────────────────────────────────────
|
|
@@ -73,6 +75,8 @@ function AgentAvatar() {
|
|
|
73
75
|
|
|
74
76
|
// ─── Main Component ────────────────────────────────────────────
|
|
75
77
|
|
|
78
|
+
const CLOSED_STATUSES = ['closed', 'resolved'];
|
|
79
|
+
|
|
76
80
|
export function SupportChatModal({
|
|
77
81
|
visible,
|
|
78
82
|
messages,
|
|
@@ -81,7 +85,9 @@ export function SupportChatModal({
|
|
|
81
85
|
isAgentTyping = false,
|
|
82
86
|
isThinking = false,
|
|
83
87
|
scrollToEndTrigger = 0,
|
|
88
|
+
ticketStatus,
|
|
84
89
|
}: SupportChatModalProps) {
|
|
90
|
+
const isClosed = !!ticketStatus && CLOSED_STATUSES.includes(ticketStatus);
|
|
85
91
|
const [text, setText] = useState('');
|
|
86
92
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
87
93
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -147,8 +153,10 @@ export function SupportChatModal({
|
|
|
147
153
|
<View style={s.headerCenter}>
|
|
148
154
|
<Text style={s.headerTitle}>Support Chat</Text>
|
|
149
155
|
<View style={s.headerStatus}>
|
|
150
|
-
<View style={s.statusDot} />
|
|
151
|
-
<Text style={s.headerSubtitle}>
|
|
156
|
+
<View style={[s.statusDot, isClosed && s.statusDotClosed]} />
|
|
157
|
+
<Text style={s.headerSubtitle}>
|
|
158
|
+
{isClosed ? 'Conversation closed' : 'Agent online'}
|
|
159
|
+
</Text>
|
|
152
160
|
</View>
|
|
153
161
|
</View>
|
|
154
162
|
<View style={s.headerBtn} />
|
|
@@ -227,26 +235,34 @@ export function SupportChatModal({
|
|
|
227
235
|
</ScrollView>
|
|
228
236
|
)}
|
|
229
237
|
|
|
230
|
-
{/* ── Input Row ── */}
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
style={s.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
238
|
+
{/* ── Input Row or Closed Banner ── */}
|
|
239
|
+
{isClosed ? (
|
|
240
|
+
<View style={s.closedBanner}>
|
|
241
|
+
<Text style={s.closedBannerText}>
|
|
242
|
+
This conversation has been closed. Start a new request to get help.
|
|
243
|
+
</Text>
|
|
244
|
+
</View>
|
|
245
|
+
) : (
|
|
246
|
+
<View style={s.inputRow}>
|
|
247
|
+
<TextInput
|
|
248
|
+
style={s.input}
|
|
249
|
+
placeholder="Type a message..."
|
|
250
|
+
placeholderTextColor="rgba(255,255,255,0.35)"
|
|
251
|
+
value={text}
|
|
252
|
+
onChangeText={setText}
|
|
253
|
+
onSubmitEditing={handleSend}
|
|
254
|
+
returnKeyType="send"
|
|
255
|
+
editable={!isThinking}
|
|
256
|
+
/>
|
|
257
|
+
<Pressable
|
|
258
|
+
style={[s.sendBtn, text.trim() && !isThinking ? s.sendBtnActive : s.sendBtnInactive]}
|
|
259
|
+
onPress={handleSend}
|
|
260
|
+
disabled={!text.trim() || isThinking}
|
|
261
|
+
>
|
|
262
|
+
<SendArrowIcon size={18} color={text.trim() && !isThinking ? '#fff' : 'rgba(255,255,255,0.3)'} />
|
|
263
|
+
</Pressable>
|
|
264
|
+
</View>
|
|
265
|
+
)}
|
|
250
266
|
</View>
|
|
251
267
|
</Modal>
|
|
252
268
|
);
|
|
@@ -312,6 +328,9 @@ const s = StyleSheet.create({
|
|
|
312
328
|
borderRadius: 4,
|
|
313
329
|
backgroundColor: '#34C759',
|
|
314
330
|
},
|
|
331
|
+
statusDotClosed: {
|
|
332
|
+
backgroundColor: '#8E8E93',
|
|
333
|
+
},
|
|
315
334
|
headerSubtitle: {
|
|
316
335
|
color: 'rgba(255,255,255,0.5)',
|
|
317
336
|
fontSize: 12,
|
|
@@ -483,6 +502,23 @@ const s = StyleSheet.create({
|
|
|
483
502
|
fontSize: 14,
|
|
484
503
|
},
|
|
485
504
|
|
|
505
|
+
// ── Closed Banner ──
|
|
506
|
+
closedBanner: {
|
|
507
|
+
paddingHorizontal: 16,
|
|
508
|
+
paddingVertical: 16,
|
|
509
|
+
paddingBottom: Platform.OS === 'ios' ? 36 : 16,
|
|
510
|
+
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
511
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
512
|
+
borderTopColor: 'rgba(255,255,255,0.06)',
|
|
513
|
+
alignItems: 'center',
|
|
514
|
+
},
|
|
515
|
+
closedBannerText: {
|
|
516
|
+
color: 'rgba(255,255,255,0.35)',
|
|
517
|
+
fontSize: 13,
|
|
518
|
+
textAlign: 'center',
|
|
519
|
+
lineHeight: 19,
|
|
520
|
+
},
|
|
521
|
+
|
|
486
522
|
// ── Input Row ──
|
|
487
523
|
inputRow: {
|
|
488
524
|
flexDirection: 'row',
|
|
@@ -14,6 +14,7 @@ import { EscalationSocket } from './EscalationSocket';
|
|
|
14
14
|
|
|
15
15
|
import { ENDPOINTS } from '../config/endpoints';
|
|
16
16
|
import { getDeviceId } from '../services/telemetry/device';
|
|
17
|
+
import { getDeviceMetadata } from '../services/telemetry/deviceMetadata';
|
|
17
18
|
import { logger } from '../utils/logger';
|
|
18
19
|
|
|
19
20
|
const MOBILEAI_HOST = ENDPOINTS.escalation;
|
|
@@ -23,6 +24,7 @@ export interface EscalationToolDeps {
|
|
|
23
24
|
analyticsKey?: string;
|
|
24
25
|
getContext: () => Omit<EscalationContext, 'conversationSummary'>;
|
|
25
26
|
getHistory: () => Array<{ role: string; content: string }>;
|
|
27
|
+
getScreenFlow?: () => string[];
|
|
26
28
|
onHumanReply?: (reply: string, ticketId?: string) => void;
|
|
27
29
|
onEscalationStarted?: (ticketId: string, socket: EscalationSocket) => void;
|
|
28
30
|
onTypingChange?: (isTyping: boolean) => void;
|
|
@@ -61,7 +63,7 @@ export function createEscalateTool(
|
|
|
61
63
|
deps = depsOrConfig as EscalationToolDeps;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
const { config, analyticsKey, getContext, getHistory, onHumanReply, onEscalationStarted, onTypingChange, onTicketClosed, userContext, pushToken, pushTokenType } = deps;
|
|
66
|
+
const { config, analyticsKey, getContext, getHistory, onHumanReply, onEscalationStarted, onTypingChange, onTicketClosed, userContext, pushToken, pushTokenType, getScreenFlow } = deps;
|
|
65
67
|
|
|
66
68
|
// Determine effective provider
|
|
67
69
|
const provider = config.provider ?? (analyticsKey ? 'mobileai' : 'custom');
|
|
@@ -103,7 +105,11 @@ export function createEscalateTool(
|
|
|
103
105
|
screen: context.currentScreen,
|
|
104
106
|
history,
|
|
105
107
|
stepsBeforeEscalation: context.stepsBeforeEscalation,
|
|
106
|
-
userContext
|
|
108
|
+
userContext: {
|
|
109
|
+
...userContext,
|
|
110
|
+
device: getDeviceMetadata(),
|
|
111
|
+
},
|
|
112
|
+
screenFlow: getScreenFlow?.() ?? [],
|
|
107
113
|
pushToken,
|
|
108
114
|
pushTokenType,
|
|
109
115
|
deviceId: getDeviceId(),
|
package/src/support/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { buildSupportPrompt } from './supportPrompt';
|
|
|
21
21
|
export { createEscalateTool } from './escalateTool';
|
|
22
22
|
export { EscalationSocket } from './EscalationSocket';
|
|
23
23
|
export type { SocketReplyHandler } from './EscalationSocket';
|
|
24
|
+
export { EscalationEventSource } from './EscalationEventSource';
|
|
24
25
|
|
|
25
26
|
// UI Components
|
|
26
27
|
export { SupportGreeting } from './SupportGreeting';
|