@jivaai/agent-chat-typescript 0.1.0
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/LICENSE +22 -0
- package/README.md +793 -0
- package/dist/api.d.ts +121 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +1032 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +97 -0
- package/dist/logger.js.map +1 -0
- package/dist/types.d.ts +340 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Jiva.ai Agent Chat API Client
|
|
4
|
+
*
|
|
5
|
+
* A simple REST API client for interacting with Jiva.ai workflows.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.JivaApiClient = void 0;
|
|
9
|
+
exports.clearEventSourceCache = clearEventSourceCache;
|
|
10
|
+
const logger_1 = require("./logger");
|
|
11
|
+
const DEFAULT_BASE_URL = 'https://api.jiva.ai/public-api/workflow';
|
|
12
|
+
const DEFAULT_SOCKET_BASE_URL = 'https://api.jiva.ai/public-api';
|
|
13
|
+
// Cache EventSource class for Node.js to avoid requiring it during reconnection
|
|
14
|
+
// (which can happen after Jest environment teardown)
|
|
15
|
+
let cachedEventSourceClass = null;
|
|
16
|
+
/**
|
|
17
|
+
* Clears the cached EventSource class (useful for testing)
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
function clearEventSourceCache() {
|
|
21
|
+
cachedEventSourceClass = null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Gets the EventSource class, using native EventSource in browsers or eventsource package in Node.js
|
|
25
|
+
*/
|
|
26
|
+
function getEventSourceClass() {
|
|
27
|
+
// Return cached version if available
|
|
28
|
+
if (cachedEventSourceClass) {
|
|
29
|
+
return cachedEventSourceClass;
|
|
30
|
+
}
|
|
31
|
+
// Browser environment - use native EventSource
|
|
32
|
+
if (typeof EventSource !== 'undefined') {
|
|
33
|
+
cachedEventSourceClass = EventSource;
|
|
34
|
+
return cachedEventSourceClass;
|
|
35
|
+
}
|
|
36
|
+
// Node.js environment - use eventsource package
|
|
37
|
+
try {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
39
|
+
// @ts-ignore - require is available in Node.js
|
|
40
|
+
const eventsource = require('eventsource');
|
|
41
|
+
// eventsource package exports EventSource as the default export or as a named export
|
|
42
|
+
const EventSourceClass = eventsource.EventSource || eventsource.default || eventsource;
|
|
43
|
+
if (typeof EventSourceClass !== 'function') {
|
|
44
|
+
throw new Error('eventsource package did not export a constructor');
|
|
45
|
+
}
|
|
46
|
+
cachedEventSourceClass = EventSourceClass;
|
|
47
|
+
return cachedEventSourceClass;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error('EventSource is not available. In Node.js, please install the "eventsource" package: npm install eventsource');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Builds the full URL for an API endpoint
|
|
55
|
+
* Format: {baseUrl}/{workflowId}/{version}/invoke
|
|
56
|
+
*/
|
|
57
|
+
function buildUrl(baseUrl, workflowId, version = '0', endpoint) {
|
|
58
|
+
const url = `${baseUrl}/${workflowId}/${version}/invoke`;
|
|
59
|
+
return endpoint ? `${url}/${endpoint}` : url;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Makes an HTTP request to the Jiva.ai API
|
|
63
|
+
*/
|
|
64
|
+
async function makeRequest(config, method, endpoint, payload, workflowId, apiKey, version, logger) {
|
|
65
|
+
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
66
|
+
const targetWorkflowId = workflowId || config.workflowId;
|
|
67
|
+
const targetApiKey = apiKey || config.apiKey;
|
|
68
|
+
// Determine version - use provided version, or determine from workflow type, or default to "0"
|
|
69
|
+
let targetVersion = version;
|
|
70
|
+
if (!targetVersion) {
|
|
71
|
+
if (workflowId === config.fileUploadCacheWorkflowId) {
|
|
72
|
+
targetVersion = config.fileUploadCacheVersion || config.workflowVersion || '0';
|
|
73
|
+
}
|
|
74
|
+
else if (workflowId === config.textUploadCacheWorkflowId) {
|
|
75
|
+
targetVersion = config.textUploadCacheVersion || config.workflowVersion || '0';
|
|
76
|
+
}
|
|
77
|
+
else if (workflowId === config.tableUploadCacheWorkflowId) {
|
|
78
|
+
targetVersion = config.tableUploadCacheVersion || config.workflowVersion || '0';
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
targetVersion = config.workflowVersion || '0';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const url = buildUrl(baseUrl, targetWorkflowId, targetVersion, endpoint);
|
|
85
|
+
const headers = {
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
'api-key': targetApiKey,
|
|
88
|
+
};
|
|
89
|
+
const options = {
|
|
90
|
+
method,
|
|
91
|
+
headers,
|
|
92
|
+
};
|
|
93
|
+
if (method === 'POST' && payload) {
|
|
94
|
+
options.body = JSON.stringify(payload);
|
|
95
|
+
}
|
|
96
|
+
logger?.debug(`Making ${method} request`, {
|
|
97
|
+
url,
|
|
98
|
+
workflowId: targetWorkflowId,
|
|
99
|
+
version: targetVersion,
|
|
100
|
+
hasPayload: !!payload,
|
|
101
|
+
payloadSize: payload ? JSON.stringify(payload).length : 0,
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(url, options);
|
|
105
|
+
const status = response.status;
|
|
106
|
+
let data;
|
|
107
|
+
let error;
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
try {
|
|
110
|
+
const jsonData = await response.json();
|
|
111
|
+
data = jsonData;
|
|
112
|
+
// Check for errorMessages in successful responses
|
|
113
|
+
// Note: We don't clear data here because the caller may need to inspect the full response
|
|
114
|
+
// even when errorMessages is present (e.g., to check json.default.state)
|
|
115
|
+
if (data && typeof data === 'object' && 'errorMessages' in data) {
|
|
116
|
+
const errorMessages = data.errorMessages;
|
|
117
|
+
if (errorMessages && errorMessages !== null) {
|
|
118
|
+
error = errorMessages;
|
|
119
|
+
logger?.warn('API returned errorMessages in successful response', {
|
|
120
|
+
status,
|
|
121
|
+
errorMessages,
|
|
122
|
+
});
|
|
123
|
+
// Don't clear data - let the caller decide how to handle it
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (error) {
|
|
127
|
+
logger?.error('Request completed with error', {
|
|
128
|
+
url,
|
|
129
|
+
status,
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
logger?.debug('Request completed successfully', {
|
|
135
|
+
url,
|
|
136
|
+
status,
|
|
137
|
+
hasData: !!data,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (parseError) {
|
|
142
|
+
// If response is not JSON, treat as success with empty data
|
|
143
|
+
logger?.warn('Response is not valid JSON, treating as success', {
|
|
144
|
+
url,
|
|
145
|
+
status,
|
|
146
|
+
});
|
|
147
|
+
data = undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
try {
|
|
152
|
+
const errorData = await response.json();
|
|
153
|
+
const errorObj = errorData;
|
|
154
|
+
error = errorObj.error || errorObj.message ||
|
|
155
|
+
(typeof errorObj.errorMessages === 'string' ? errorObj.errorMessages : null) ||
|
|
156
|
+
`HTTP ${status}`;
|
|
157
|
+
logger?.error('Request failed', {
|
|
158
|
+
url,
|
|
159
|
+
status,
|
|
160
|
+
error,
|
|
161
|
+
errorData,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
error = `HTTP ${status}: ${response.statusText}`;
|
|
166
|
+
logger?.error('Request failed and response is not JSON', {
|
|
167
|
+
url,
|
|
168
|
+
status,
|
|
169
|
+
statusText: response.statusText,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { data, error, status };
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
177
|
+
logger?.error('Network error during request', {
|
|
178
|
+
url,
|
|
179
|
+
error: errorMessage,
|
|
180
|
+
originalError: err,
|
|
181
|
+
});
|
|
182
|
+
return { error: errorMessage, status: 0 };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Jiva.ai API Client
|
|
187
|
+
*/
|
|
188
|
+
class JivaApiClient {
|
|
189
|
+
;
|
|
190
|
+
constructor(config) {
|
|
191
|
+
// Track reconnect attempts per sessionId to persist across recursive calls
|
|
192
|
+
this.reconnectAttemptsMap = new Map();
|
|
193
|
+
// Track if a reconnect is already scheduled for a sessionId
|
|
194
|
+
this.reconnectScheduledMap = new Map();
|
|
195
|
+
if (!config.apiKey) {
|
|
196
|
+
throw new Error('API key is required');
|
|
197
|
+
}
|
|
198
|
+
if (!config.workflowId) {
|
|
199
|
+
throw new Error('Workflow ID is required');
|
|
200
|
+
}
|
|
201
|
+
if (!config.fileUploadCacheWorkflowId) {
|
|
202
|
+
throw new Error('File Upload Cache Workflow ID is required');
|
|
203
|
+
}
|
|
204
|
+
if (!config.textUploadCacheWorkflowId) {
|
|
205
|
+
throw new Error('Text Upload Cache Workflow ID is required');
|
|
206
|
+
}
|
|
207
|
+
if (!config.tableUploadCacheWorkflowId) {
|
|
208
|
+
throw new Error('Table Upload Cache Workflow ID is required');
|
|
209
|
+
}
|
|
210
|
+
this.config = config;
|
|
211
|
+
this.logger = (0, logger_1.createLogger)(config.logging);
|
|
212
|
+
this.logger.debug('JivaApiClient initialized', {
|
|
213
|
+
workflowId: config.workflowId,
|
|
214
|
+
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Makes a GET request to the API
|
|
219
|
+
*
|
|
220
|
+
* @param endpoint - Optional endpoint path (appended to base URL)
|
|
221
|
+
* @param onSuccess - Optional success callback
|
|
222
|
+
* @param onError - Optional error callback
|
|
223
|
+
* @returns Promise with the API response
|
|
224
|
+
*/
|
|
225
|
+
async get(endpoint, onSuccess, onError) {
|
|
226
|
+
this.logger.debug('Making GET request', { endpoint });
|
|
227
|
+
const response = await makeRequest(this.config, 'GET', endpoint, undefined, undefined, undefined, undefined, this.logger);
|
|
228
|
+
if (response.error) {
|
|
229
|
+
onError?.(response.error, response.status);
|
|
230
|
+
}
|
|
231
|
+
else if (response.data !== undefined) {
|
|
232
|
+
onSuccess?.(response.data, response.status);
|
|
233
|
+
}
|
|
234
|
+
return response;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Makes a POST request to the API
|
|
238
|
+
*
|
|
239
|
+
* @param payload - JSON payload to send
|
|
240
|
+
* @param endpoint - Optional endpoint path (appended to base URL)
|
|
241
|
+
* @param onSuccess - Optional success callback
|
|
242
|
+
* @param onError - Optional error callback
|
|
243
|
+
* @returns Promise with the API response
|
|
244
|
+
*/
|
|
245
|
+
async post(payload = {}, endpoint, onSuccess, onError) {
|
|
246
|
+
this.logger.debug('Making POST request', { endpoint, hasPayload: !!payload });
|
|
247
|
+
const response = await makeRequest(this.config, 'POST', endpoint, payload, undefined, undefined, undefined, this.logger);
|
|
248
|
+
if (response.error) {
|
|
249
|
+
onError?.(response.error, response.status);
|
|
250
|
+
}
|
|
251
|
+
else if (response.data !== undefined || response.status === 200 || response.status === 201) {
|
|
252
|
+
onSuccess?.(response.data, response.status);
|
|
253
|
+
}
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Polls for the status of a running conversation
|
|
258
|
+
*
|
|
259
|
+
* @param sessionId - The session ID from the original request
|
|
260
|
+
* @param executionId - The ID from the RUNNING response
|
|
261
|
+
* @param options - Polling options
|
|
262
|
+
* @returns Promise with the poll response
|
|
263
|
+
*/
|
|
264
|
+
async pollConversationStatus(sessionId, executionId, options = {}) {
|
|
265
|
+
const maxAttempts = options.maxAttempts || 30;
|
|
266
|
+
const pollInterval = options.pollInterval || 1000;
|
|
267
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
269
|
+
const pollRequest = {
|
|
270
|
+
sessionId,
|
|
271
|
+
id: executionId,
|
|
272
|
+
mode: 'POLL_REQUEST',
|
|
273
|
+
};
|
|
274
|
+
const payload = {
|
|
275
|
+
data: {
|
|
276
|
+
default: [pollRequest],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
this.logger.debug('Polling conversation status', {
|
|
280
|
+
sessionId,
|
|
281
|
+
executionId,
|
|
282
|
+
attempt: attempt + 1,
|
|
283
|
+
maxAttempts,
|
|
284
|
+
});
|
|
285
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, undefined, undefined, undefined, this.logger);
|
|
286
|
+
if (response.error) {
|
|
287
|
+
return response;
|
|
288
|
+
}
|
|
289
|
+
const state = response.data?.json?.default?.state;
|
|
290
|
+
if (state === 'OK' || state === 'ERROR' || state === 'PARTIAL_OK') {
|
|
291
|
+
return response;
|
|
292
|
+
}
|
|
293
|
+
// If still RUNNING, continue polling
|
|
294
|
+
if (state === 'RUNNING') {
|
|
295
|
+
this.logger.debug('Poll response still RUNNING, continuing', {
|
|
296
|
+
sessionId,
|
|
297
|
+
executionId,
|
|
298
|
+
attempt: attempt + 1,
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.logger.warn('Polling timeout reached', {
|
|
304
|
+
sessionId,
|
|
305
|
+
executionId,
|
|
306
|
+
maxAttempts,
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
error: 'Polling timeout: Maximum attempts reached',
|
|
310
|
+
status: 0,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Polls for the status of a running conversation (public method)
|
|
315
|
+
*
|
|
316
|
+
* @param request - Poll request with sessionId, id, and mode
|
|
317
|
+
* @param onSuccess - Optional success callback
|
|
318
|
+
* @param onError - Optional error callback
|
|
319
|
+
* @returns Promise with the poll response
|
|
320
|
+
*/
|
|
321
|
+
async poll(request, onSuccess, onError) {
|
|
322
|
+
// Validate required fields
|
|
323
|
+
if (!request.sessionId) {
|
|
324
|
+
const error = 'sessionId is required';
|
|
325
|
+
onError?.(error);
|
|
326
|
+
return { error, status: 400 };
|
|
327
|
+
}
|
|
328
|
+
if (!request.id) {
|
|
329
|
+
const error = 'id is required';
|
|
330
|
+
onError?.(error);
|
|
331
|
+
return { error, status: 400 };
|
|
332
|
+
}
|
|
333
|
+
if (request.mode !== 'POLL_REQUEST') {
|
|
334
|
+
const error = 'mode must be POLL_REQUEST';
|
|
335
|
+
onError?.(error);
|
|
336
|
+
return { error, status: 400 };
|
|
337
|
+
}
|
|
338
|
+
// Build the nested payload structure (array must be of size 1)
|
|
339
|
+
const payload = {
|
|
340
|
+
data: {
|
|
341
|
+
default: [request],
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
this.logger.debug('Making poll request', {
|
|
345
|
+
sessionId: request.sessionId,
|
|
346
|
+
id: request.id,
|
|
347
|
+
});
|
|
348
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, undefined, undefined, undefined, this.logger);
|
|
349
|
+
if (response.error) {
|
|
350
|
+
this.logger.error('Poll request failed', {
|
|
351
|
+
sessionId: request.sessionId,
|
|
352
|
+
id: request.id,
|
|
353
|
+
error: response.error,
|
|
354
|
+
});
|
|
355
|
+
onError?.(response.error, response.status);
|
|
356
|
+
}
|
|
357
|
+
else if (response.data) {
|
|
358
|
+
this.logger.debug('Poll request successful', {
|
|
359
|
+
sessionId: request.sessionId,
|
|
360
|
+
id: request.id,
|
|
361
|
+
state: response.data.json?.default?.state,
|
|
362
|
+
});
|
|
363
|
+
onSuccess?.(response.data, response.status);
|
|
364
|
+
}
|
|
365
|
+
return response;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Validates and normalizes conversation messages
|
|
369
|
+
*/
|
|
370
|
+
validateAndNormalizeMessages(request) {
|
|
371
|
+
let messages;
|
|
372
|
+
let sessionId;
|
|
373
|
+
// Handle single message or array of messages
|
|
374
|
+
if (Array.isArray(request)) {
|
|
375
|
+
if (request.length === 0) {
|
|
376
|
+
return { error: 'At least one message is required' };
|
|
377
|
+
}
|
|
378
|
+
messages = request.map((msg) => ({
|
|
379
|
+
sessionId: msg.sessionId,
|
|
380
|
+
message: msg.message,
|
|
381
|
+
mode: msg.mode,
|
|
382
|
+
nodeId: msg.nodeId,
|
|
383
|
+
field: msg.field,
|
|
384
|
+
assetId: msg.assetId,
|
|
385
|
+
}));
|
|
386
|
+
sessionId = request[0].sessionId;
|
|
387
|
+
// Validate all messages have the same sessionId
|
|
388
|
+
for (const msg of request) {
|
|
389
|
+
if (!msg.sessionId) {
|
|
390
|
+
return { error: 'sessionId is required for all messages' };
|
|
391
|
+
}
|
|
392
|
+
if (msg.sessionId !== sessionId) {
|
|
393
|
+
return { error: 'All messages must have the same sessionId' };
|
|
394
|
+
}
|
|
395
|
+
if (!msg.message) {
|
|
396
|
+
return { error: 'message is required for all messages' };
|
|
397
|
+
}
|
|
398
|
+
if (!msg.mode || !['CHAT_REQUEST', 'CHAT_RESPONSE'].includes(msg.mode)) {
|
|
399
|
+
return { error: 'mode must be CHAT_REQUEST or CHAT_RESPONSE for context messages' };
|
|
400
|
+
}
|
|
401
|
+
// Validate screen satisfaction fields: if any are provided, all must be provided
|
|
402
|
+
if (msg.nodeId || msg.field || msg.assetId) {
|
|
403
|
+
if (!msg.nodeId || !msg.field || !msg.assetId) {
|
|
404
|
+
return { error: 'When satisfying a screen, nodeId, field, and assetId must all be provided' };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Validate that CHAT_REQUEST and CHAT_RESPONSE alternate
|
|
409
|
+
for (let i = 1; i < messages.length; i++) {
|
|
410
|
+
const previousMode = messages[i - 1].mode;
|
|
411
|
+
const currentMode = messages[i].mode;
|
|
412
|
+
if (previousMode === currentMode) {
|
|
413
|
+
return {
|
|
414
|
+
error: `CHAT_REQUEST and CHAT_RESPONSE must alternate. Messages at indices ${i - 1} and ${i} both have mode ${currentMode}`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
// Single message
|
|
421
|
+
if (!request.sessionId) {
|
|
422
|
+
return { error: 'sessionId is required' };
|
|
423
|
+
}
|
|
424
|
+
if (!request.message) {
|
|
425
|
+
return { error: 'message is required' };
|
|
426
|
+
}
|
|
427
|
+
if (!request.mode || !['CHAT_REQUEST', 'CHAT_RESPONSE', 'SCREEN_RESPONSE'].includes(request.mode)) {
|
|
428
|
+
return { error: 'mode must be CHAT_REQUEST, CHAT_RESPONSE, or SCREEN_RESPONSE' };
|
|
429
|
+
}
|
|
430
|
+
sessionId = request.sessionId;
|
|
431
|
+
const messagePayload = {
|
|
432
|
+
sessionId: request.sessionId,
|
|
433
|
+
message: request.message,
|
|
434
|
+
mode: request.mode, // Preserve SCREEN_RESPONSE for single messages
|
|
435
|
+
nodeId: request.nodeId,
|
|
436
|
+
field: request.field,
|
|
437
|
+
assetId: request.assetId,
|
|
438
|
+
};
|
|
439
|
+
// Validate screen satisfaction fields: if any are provided, all must be provided
|
|
440
|
+
if (request.nodeId || request.field || request.assetId) {
|
|
441
|
+
if (!request.nodeId || !request.field || !request.assetId) {
|
|
442
|
+
return { error: 'When satisfying a screen, nodeId, field, and assetId must all be provided' };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
messages = [messagePayload];
|
|
446
|
+
}
|
|
447
|
+
return { messages, sessionId };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Initiates a conversation with the Jiva.ai agent
|
|
451
|
+
*
|
|
452
|
+
* @param request - Conversation request (single message or array of messages for context)
|
|
453
|
+
* @param options - Optional polling options for RUNNING state
|
|
454
|
+
* @param onSuccess - Optional success callback
|
|
455
|
+
* @param onError - Optional error callback
|
|
456
|
+
* @returns Promise with the conversation response
|
|
457
|
+
*/
|
|
458
|
+
async initiateConversation(request, options = {}, onSuccess, onError) {
|
|
459
|
+
// Validate and normalize messages
|
|
460
|
+
const validation = this.validateAndNormalizeMessages(request);
|
|
461
|
+
if (validation.error) {
|
|
462
|
+
onError?.(validation.error);
|
|
463
|
+
return { error: validation.error, status: 400 };
|
|
464
|
+
}
|
|
465
|
+
const messages = validation.messages;
|
|
466
|
+
const sessionId = validation.sessionId;
|
|
467
|
+
// Build the nested payload structure
|
|
468
|
+
const payload = {
|
|
469
|
+
data: {
|
|
470
|
+
default: messages,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
// Make the initial request
|
|
474
|
+
this.logger.info('Initiating conversation', {
|
|
475
|
+
sessionId,
|
|
476
|
+
messageCount: messages.length,
|
|
477
|
+
isContext: Array.isArray(request),
|
|
478
|
+
});
|
|
479
|
+
this.logger.debug('Conversation payload', {
|
|
480
|
+
sessionId,
|
|
481
|
+
messages: messages.map(m => ({ mode: m.mode, hasMessage: !!m.message })),
|
|
482
|
+
});
|
|
483
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, undefined, undefined, undefined, this.logger);
|
|
484
|
+
if (response.error) {
|
|
485
|
+
onError?.(response.error, response.status);
|
|
486
|
+
return response;
|
|
487
|
+
}
|
|
488
|
+
// Check if we need to poll for the result
|
|
489
|
+
if (response.data?.json?.default?.state === 'RUNNING' && response.data.json.default.id) {
|
|
490
|
+
this.logger.info('Conversation state is RUNNING, starting polling', {
|
|
491
|
+
sessionId,
|
|
492
|
+
executionId: response.data.json.default.id,
|
|
493
|
+
});
|
|
494
|
+
const pollResponse = await this.pollConversationStatus(sessionId, response.data.json.default.id, options);
|
|
495
|
+
if (pollResponse.error) {
|
|
496
|
+
this.logger.error('Polling failed', {
|
|
497
|
+
sessionId,
|
|
498
|
+
executionId: response.data.json.default.id,
|
|
499
|
+
error: pollResponse.error,
|
|
500
|
+
});
|
|
501
|
+
onError?.(pollResponse.error, pollResponse.status);
|
|
502
|
+
return { error: pollResponse.error, status: pollResponse.status };
|
|
503
|
+
}
|
|
504
|
+
// Convert PollResponse to ConversationResponse format for consistency
|
|
505
|
+
if (pollResponse.data) {
|
|
506
|
+
// Check for errors in poll response
|
|
507
|
+
if (pollResponse.data.json?.default?.state === 'ERROR') {
|
|
508
|
+
const errorMsg = pollResponse.data.json.default.logs?.join('\n') ||
|
|
509
|
+
pollResponse.data.errorMessages ||
|
|
510
|
+
'Request failed';
|
|
511
|
+
this.logger.error('Poll response indicates ERROR state', {
|
|
512
|
+
sessionId,
|
|
513
|
+
executionId: response.data.json.default.id,
|
|
514
|
+
errorMsg,
|
|
515
|
+
});
|
|
516
|
+
onError?.(errorMsg, pollResponse.status);
|
|
517
|
+
return { error: errorMsg, status: pollResponse.status };
|
|
518
|
+
}
|
|
519
|
+
const conversationResponse = {
|
|
520
|
+
workflowExecutionId: pollResponse.data.workflowExecutionId,
|
|
521
|
+
errorMessages: pollResponse.data.errorMessages,
|
|
522
|
+
data: pollResponse.data.data,
|
|
523
|
+
strings: pollResponse.data.strings,
|
|
524
|
+
base64Files: pollResponse.data.base64Files,
|
|
525
|
+
vectorDatabaseIndexIds: pollResponse.data.vectorDatabaseIndexIds,
|
|
526
|
+
metadata: pollResponse.data.metadata,
|
|
527
|
+
json: {
|
|
528
|
+
default: {
|
|
529
|
+
message: pollResponse.data.json.default.logs?.join('\n') || '',
|
|
530
|
+
state: pollResponse.data.json.default.state,
|
|
531
|
+
mode: 'CHAT_RESPONSE', // Convert POLL_RESPONSE to CHAT_RESPONSE
|
|
532
|
+
executions: pollResponse.data.json.default.executions?.map((exec) => ({
|
|
533
|
+
response: exec.output.response,
|
|
534
|
+
type: exec.output.type,
|
|
535
|
+
data: exec.output.data,
|
|
536
|
+
})),
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
this.logger.info('Polling completed successfully', {
|
|
541
|
+
sessionId,
|
|
542
|
+
executionId: response.data.json.default.id,
|
|
543
|
+
state: conversationResponse.json.default.state,
|
|
544
|
+
});
|
|
545
|
+
onSuccess?.(conversationResponse, pollResponse.status);
|
|
546
|
+
return { data: conversationResponse, status: pollResponse.status };
|
|
547
|
+
}
|
|
548
|
+
this.logger.warn('Poll response has no data', {
|
|
549
|
+
sessionId,
|
|
550
|
+
executionId: response.data.json.default.id,
|
|
551
|
+
});
|
|
552
|
+
return { error: 'No data in poll response', status: pollResponse.status };
|
|
553
|
+
}
|
|
554
|
+
// Immediate response (OK or ERROR)
|
|
555
|
+
if (response.data) {
|
|
556
|
+
if (response.data.json?.default?.state === 'ERROR') {
|
|
557
|
+
// Check for error message in json.default.message first, then errorMessages
|
|
558
|
+
const errorMsg = response.data.json.default.message ||
|
|
559
|
+
response.data.errorMessages ||
|
|
560
|
+
'Request failed';
|
|
561
|
+
this.logger.error('Conversation response indicates ERROR state', {
|
|
562
|
+
sessionId,
|
|
563
|
+
errorMsg,
|
|
564
|
+
state: response.data.json.default.state,
|
|
565
|
+
});
|
|
566
|
+
onError?.(errorMsg, response.status);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
this.logger.info('Conversation completed immediately', {
|
|
570
|
+
sessionId,
|
|
571
|
+
state: response.data.json.default.state,
|
|
572
|
+
mode: response.data.json.default.mode,
|
|
573
|
+
});
|
|
574
|
+
onSuccess?.(response.data, response.status);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return response;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Uploads a file to the File Upload Cache
|
|
581
|
+
*
|
|
582
|
+
* @param file - The file to upload (File, Blob, or base64 string)
|
|
583
|
+
* @param onSuccess - Optional success callback
|
|
584
|
+
* @param onError - Optional error callback
|
|
585
|
+
* @returns Promise with the upload response containing the assetId
|
|
586
|
+
*/
|
|
587
|
+
async uploadFile(file, onSuccess, onError) {
|
|
588
|
+
this.logger.info('Uploading file', {
|
|
589
|
+
fileType: file instanceof File ? 'File' : file instanceof Blob ? 'Blob' : 'base64',
|
|
590
|
+
});
|
|
591
|
+
// Build payload - structure depends on file type
|
|
592
|
+
let base64Content;
|
|
593
|
+
if (file instanceof File || file instanceof Blob) {
|
|
594
|
+
// For File/Blob, we need to convert to base64
|
|
595
|
+
this.logger.debug('Converting File/Blob to base64');
|
|
596
|
+
base64Content = await this.fileToBase64(file);
|
|
597
|
+
this.logger.debug('File converted to base64', {
|
|
598
|
+
size: base64Content.length,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
// Assume it's already a base64 string
|
|
603
|
+
this.logger.debug('Using provided base64 string', {
|
|
604
|
+
size: file.length,
|
|
605
|
+
});
|
|
606
|
+
base64Content = file;
|
|
607
|
+
}
|
|
608
|
+
// File upload cache expects: { "base64FileBytes": { "default": "base64 content" } }
|
|
609
|
+
// The Spring backend expects the key to be "default" (or a configured key from the workflow node)
|
|
610
|
+
const payload = {
|
|
611
|
+
base64FileBytes: {
|
|
612
|
+
default: base64Content,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, this.config.fileUploadCacheWorkflowId, this.config.fileUploadCacheApiKey || this.config.apiKey, this.config.fileUploadCacheVersion || this.config.workflowVersion || '0', this.logger);
|
|
616
|
+
if (response.error) {
|
|
617
|
+
this.logger.error('File upload failed', {
|
|
618
|
+
error: response.error,
|
|
619
|
+
status: response.status,
|
|
620
|
+
});
|
|
621
|
+
onError?.(response.error, response.status);
|
|
622
|
+
}
|
|
623
|
+
else if (response.data?.strings?.default) {
|
|
624
|
+
this.logger.info('File upload successful', {
|
|
625
|
+
assetId: response.data.strings.default,
|
|
626
|
+
});
|
|
627
|
+
onSuccess?.(response.data, response.status);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
const error = 'No assetId in upload response';
|
|
631
|
+
this.logger.error('File upload response missing assetId', {
|
|
632
|
+
response: response.data,
|
|
633
|
+
});
|
|
634
|
+
onError?.(error);
|
|
635
|
+
return { error, status: response.status };
|
|
636
|
+
}
|
|
637
|
+
return response;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Uploads text to the Text Upload Cache
|
|
641
|
+
*
|
|
642
|
+
* @param text - The text content to upload
|
|
643
|
+
* @param onSuccess - Optional success callback
|
|
644
|
+
* @param onError - Optional error callback
|
|
645
|
+
* @returns Promise with the upload response containing the assetId
|
|
646
|
+
*/
|
|
647
|
+
async uploadText(text, onSuccess, onError) {
|
|
648
|
+
if (!text) {
|
|
649
|
+
const error = 'Text content is required';
|
|
650
|
+
this.logger.warn('uploadText called with empty text');
|
|
651
|
+
onError?.(error);
|
|
652
|
+
return { error, status: 400 };
|
|
653
|
+
}
|
|
654
|
+
this.logger.info('Uploading text', {
|
|
655
|
+
textLength: text.length,
|
|
656
|
+
});
|
|
657
|
+
// Text upload cache expects: { "strings": { "default": "text content" } }
|
|
658
|
+
// The Spring backend expects strings to be a Map<String, String> where the key is "default" (or configured)
|
|
659
|
+
const payload = {
|
|
660
|
+
strings: {
|
|
661
|
+
default: text,
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, this.config.textUploadCacheWorkflowId, this.config.textUploadCacheApiKey || this.config.apiKey, this.config.textUploadCacheVersion || this.config.workflowVersion || '0', this.logger);
|
|
665
|
+
if (response.error) {
|
|
666
|
+
this.logger.error('Text upload failed', {
|
|
667
|
+
error: response.error,
|
|
668
|
+
status: response.status,
|
|
669
|
+
});
|
|
670
|
+
onError?.(response.error, response.status);
|
|
671
|
+
}
|
|
672
|
+
else if (response.data?.strings?.default) {
|
|
673
|
+
this.logger.info('Text upload successful', {
|
|
674
|
+
assetId: response.data.strings.default,
|
|
675
|
+
});
|
|
676
|
+
onSuccess?.(response.data, response.status);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
const error = 'No assetId in upload response';
|
|
680
|
+
this.logger.error('Text upload response missing assetId', {
|
|
681
|
+
response: response.data,
|
|
682
|
+
});
|
|
683
|
+
onError?.(error);
|
|
684
|
+
return { error, status: response.status };
|
|
685
|
+
}
|
|
686
|
+
return response;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Uploads table data to the Table Upload Cache
|
|
690
|
+
*
|
|
691
|
+
* @param tableData - The table data to upload (array of objects with consistent structure)
|
|
692
|
+
* @param onSuccess - Optional success callback
|
|
693
|
+
* @param onError - Optional error callback
|
|
694
|
+
* @returns Promise with the upload response containing the assetId
|
|
695
|
+
*/
|
|
696
|
+
async uploadTable(tableData, onSuccess, onError) {
|
|
697
|
+
if (!tableData || tableData.length === 0) {
|
|
698
|
+
const error = 'Table data is required and must not be empty';
|
|
699
|
+
this.logger.warn('uploadTable called with empty table data');
|
|
700
|
+
onError?.(error);
|
|
701
|
+
return { error, status: 400 };
|
|
702
|
+
}
|
|
703
|
+
this.logger.info('Uploading table', {
|
|
704
|
+
rowCount: tableData.length,
|
|
705
|
+
});
|
|
706
|
+
// Table upload cache expects: { "data": { "default": [array of row objects] } }
|
|
707
|
+
// The Spring backend expects data to be a Map<String, List<Map<String, Object>>>
|
|
708
|
+
// where the key is "default" (or configured) and the value is the table data directly
|
|
709
|
+
const payload = {
|
|
710
|
+
data: {
|
|
711
|
+
default: tableData,
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
const response = await makeRequest(this.config, 'POST', undefined, payload, this.config.tableUploadCacheWorkflowId, this.config.tableUploadCacheApiKey || this.config.apiKey, this.config.tableUploadCacheVersion || this.config.workflowVersion || '0', this.logger);
|
|
715
|
+
if (response.error) {
|
|
716
|
+
onError?.(response.error, response.status);
|
|
717
|
+
}
|
|
718
|
+
else if (response.data) {
|
|
719
|
+
onSuccess?.(response.data, response.status);
|
|
720
|
+
}
|
|
721
|
+
return response;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Helper method to convert File/Blob to base64 string
|
|
725
|
+
*/
|
|
726
|
+
async fileToBase64(file) {
|
|
727
|
+
// Check if FileReader is available (browser environment)
|
|
728
|
+
if (typeof FileReader !== 'undefined') {
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
const reader = new FileReader();
|
|
731
|
+
reader.onload = () => {
|
|
732
|
+
const result = reader.result;
|
|
733
|
+
// Remove data URL prefix if present
|
|
734
|
+
const base64 = result.includes(',') ? result.split(',')[1] : result;
|
|
735
|
+
resolve(base64);
|
|
736
|
+
};
|
|
737
|
+
reader.onerror = reject;
|
|
738
|
+
reader.readAsDataURL(file);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
// Node.js environment - File/Blob should already be base64 or use Buffer
|
|
743
|
+
throw new Error('FileReader is not available. In Node.js, please provide base64 strings directly.');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Creates a Server-Sent Events (SSE) connection to subscribe to real-time agent updates
|
|
748
|
+
* Uses POST request to initiate the connection, then streams SSE events.
|
|
749
|
+
*
|
|
750
|
+
* The backend Spring implementation sends an initial "connected" event when the connection is established,
|
|
751
|
+
* followed by streaming agent update messages.
|
|
752
|
+
*
|
|
753
|
+
* @param sessionId - The session ID to subscribe to
|
|
754
|
+
* @param callbacks - Event callbacks for socket events
|
|
755
|
+
* @param options - Socket connection options
|
|
756
|
+
* @returns An object that mimics EventSource interface for compatibility
|
|
757
|
+
*/
|
|
758
|
+
subscribeToSocket(sessionId, callbacks = {}, options = {}) {
|
|
759
|
+
if (!sessionId) {
|
|
760
|
+
throw new Error('sessionId is required for socket subscription');
|
|
761
|
+
}
|
|
762
|
+
// For sockets, use socketBaseUrl if provided, otherwise derive from baseUrl, or use default
|
|
763
|
+
let socketBaseUrl;
|
|
764
|
+
if (this.config.socketBaseUrl) {
|
|
765
|
+
socketBaseUrl = this.config.socketBaseUrl;
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
const baseUrl = this.config.baseUrl || DEFAULT_BASE_URL;
|
|
769
|
+
// Derive socket base URL by removing /workflow from the end
|
|
770
|
+
socketBaseUrl = baseUrl.replace(/\/workflow\/?$/, '') || DEFAULT_SOCKET_BASE_URL;
|
|
771
|
+
}
|
|
772
|
+
// Construct URL: POST https://{SOCKET_BASE_URL}/workflow-chat/{workflowId}/{sessionId}
|
|
773
|
+
const url = `${socketBaseUrl}/workflow-chat/${this.config.workflowId}/${sessionId}`;
|
|
774
|
+
this.logger.info('Creating SSE connection via POST', {
|
|
775
|
+
sessionId,
|
|
776
|
+
url,
|
|
777
|
+
workflowId: this.config.workflowId,
|
|
778
|
+
socketBaseUrl,
|
|
779
|
+
autoReconnect: options.autoReconnect ?? true,
|
|
780
|
+
});
|
|
781
|
+
// Create a controller to manage the connection
|
|
782
|
+
let abortController = null;
|
|
783
|
+
let isClosed = false;
|
|
784
|
+
const CONNECTING = 0;
|
|
785
|
+
const OPEN = 1;
|
|
786
|
+
const CLOSED = 2;
|
|
787
|
+
let readyState = CONNECTING;
|
|
788
|
+
// Get or initialize reconnect attempts for this sessionId
|
|
789
|
+
const reconnectAttempts = this.reconnectAttemptsMap.get(sessionId) ?? 0;
|
|
790
|
+
const maxAttempts = options.maxReconnectAttempts ?? 10;
|
|
791
|
+
const reconnectInterval = options.reconnectInterval ?? 3000;
|
|
792
|
+
const autoReconnect = options.autoReconnect ?? true;
|
|
793
|
+
let reconnectTimeout = null;
|
|
794
|
+
let isConnected = false;
|
|
795
|
+
let isReconnecting = false;
|
|
796
|
+
const attemptReconnect = () => {
|
|
797
|
+
// Prevent multiple simultaneous reconnect attempts
|
|
798
|
+
if (isReconnecting || this.reconnectScheduledMap.get(sessionId)) {
|
|
799
|
+
this.logger.debug('Reconnect already in progress, skipping', { sessionId });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (!autoReconnect || reconnectAttempts >= maxAttempts) {
|
|
803
|
+
this.logger.warn('Max reconnection attempts reached or autoReconnect disabled', {
|
|
804
|
+
sessionId,
|
|
805
|
+
reconnectAttempts,
|
|
806
|
+
maxAttempts,
|
|
807
|
+
});
|
|
808
|
+
// Clean up tracking
|
|
809
|
+
this.reconnectAttemptsMap.delete(sessionId);
|
|
810
|
+
this.reconnectScheduledMap.delete(sessionId);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Mark that we're scheduling a reconnect
|
|
814
|
+
this.reconnectScheduledMap.set(sessionId, true);
|
|
815
|
+
isReconnecting = true;
|
|
816
|
+
// Increment and store reconnect attempts
|
|
817
|
+
const newAttemptCount = reconnectAttempts + 1;
|
|
818
|
+
this.reconnectAttemptsMap.set(sessionId, newAttemptCount);
|
|
819
|
+
this.logger.info('Scheduling SSE reconnection', {
|
|
820
|
+
sessionId,
|
|
821
|
+
attempt: newAttemptCount,
|
|
822
|
+
maxAttempts,
|
|
823
|
+
delayMs: reconnectInterval,
|
|
824
|
+
});
|
|
825
|
+
callbacks.onReconnect?.(newAttemptCount);
|
|
826
|
+
reconnectTimeout = setTimeout(() => {
|
|
827
|
+
// Clear the scheduled flag before attempting reconnect
|
|
828
|
+
this.reconnectScheduledMap.delete(sessionId);
|
|
829
|
+
isReconnecting = false;
|
|
830
|
+
// Close the old connection before creating a new one
|
|
831
|
+
if (abortController) {
|
|
832
|
+
abortController.abort();
|
|
833
|
+
}
|
|
834
|
+
// Create a new connection (will use the updated reconnectAttempts from the map)
|
|
835
|
+
this.subscribeToSocket(sessionId, callbacks, options);
|
|
836
|
+
}, reconnectInterval);
|
|
837
|
+
};
|
|
838
|
+
const startConnection = async () => {
|
|
839
|
+
abortController = new AbortController();
|
|
840
|
+
readyState = CONNECTING;
|
|
841
|
+
isClosed = false;
|
|
842
|
+
try {
|
|
843
|
+
// Prepare headers with API key
|
|
844
|
+
const headers = {
|
|
845
|
+
'Content-Type': 'application/json',
|
|
846
|
+
'api-key': this.config.apiKey,
|
|
847
|
+
'Accept': 'text/event-stream',
|
|
848
|
+
};
|
|
849
|
+
this.logger.debug('SSE connection details', {
|
|
850
|
+
url,
|
|
851
|
+
method: 'POST',
|
|
852
|
+
hasApiKey: !!this.config.apiKey,
|
|
853
|
+
apiKeyLength: this.config.apiKey?.length || 0,
|
|
854
|
+
apiKeyPrefix: this.config.apiKey?.substring(0, 10) || 'N/A',
|
|
855
|
+
headers: Object.keys(headers),
|
|
856
|
+
});
|
|
857
|
+
// Make POST request to initiate SSE stream
|
|
858
|
+
const response = await fetch(url, {
|
|
859
|
+
method: 'POST',
|
|
860
|
+
headers,
|
|
861
|
+
body: JSON.stringify({}), // Empty body for POST
|
|
862
|
+
signal: abortController.signal,
|
|
863
|
+
});
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
const errorDetails = {
|
|
866
|
+
sessionId,
|
|
867
|
+
status: response.status,
|
|
868
|
+
statusText: response.statusText,
|
|
869
|
+
};
|
|
870
|
+
this.logger.error('SSE connection failed', errorDetails);
|
|
871
|
+
callbacks.onError?.(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
872
|
+
if (!isConnected && !isReconnecting) {
|
|
873
|
+
attemptReconnect();
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
// Connection established
|
|
878
|
+
readyState = OPEN;
|
|
879
|
+
isConnected = true;
|
|
880
|
+
isReconnecting = false;
|
|
881
|
+
this.reconnectAttemptsMap.delete(sessionId);
|
|
882
|
+
this.reconnectScheduledMap.delete(sessionId);
|
|
883
|
+
if (reconnectTimeout) {
|
|
884
|
+
clearTimeout(reconnectTimeout);
|
|
885
|
+
reconnectTimeout = null;
|
|
886
|
+
}
|
|
887
|
+
callbacks.onOpen?.();
|
|
888
|
+
// Parse SSE stream manually
|
|
889
|
+
const reader = response.body?.getReader();
|
|
890
|
+
if (!reader) {
|
|
891
|
+
throw new Error('Response body is not readable');
|
|
892
|
+
}
|
|
893
|
+
const decoder = new TextDecoder();
|
|
894
|
+
let buffer = '';
|
|
895
|
+
while (true) {
|
|
896
|
+
const { done, value } = await reader.read();
|
|
897
|
+
if (done) {
|
|
898
|
+
// Stream ended
|
|
899
|
+
readyState = CLOSED;
|
|
900
|
+
isConnected = false;
|
|
901
|
+
this.logger.info('SSE connection closed', { sessionId });
|
|
902
|
+
callbacks.onClose?.({
|
|
903
|
+
code: 0,
|
|
904
|
+
reason: 'Stream ended',
|
|
905
|
+
wasClean: true,
|
|
906
|
+
});
|
|
907
|
+
attemptReconnect();
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
// Decode chunk and add to buffer
|
|
911
|
+
buffer += decoder.decode(value, { stream: true });
|
|
912
|
+
// Process complete SSE messages (lines ending with \n\n)
|
|
913
|
+
let eventEndIndex;
|
|
914
|
+
while ((eventEndIndex = buffer.indexOf('\n\n')) !== -1) {
|
|
915
|
+
const eventText = buffer.substring(0, eventEndIndex);
|
|
916
|
+
buffer = buffer.substring(eventEndIndex + 2);
|
|
917
|
+
// Parse SSE format: "event: <name>\ndata: <data>"
|
|
918
|
+
let eventName = 'message';
|
|
919
|
+
let eventData = '';
|
|
920
|
+
for (const line of eventText.split('\n')) {
|
|
921
|
+
if (line.startsWith('event:')) {
|
|
922
|
+
eventName = line.substring(6).trim();
|
|
923
|
+
}
|
|
924
|
+
else if (line.startsWith('data:')) {
|
|
925
|
+
eventData = line.substring(5).trim();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Handle "connected" event
|
|
929
|
+
if (eventName === 'connected') {
|
|
930
|
+
this.logger.info('SSE connection confirmed by server', {
|
|
931
|
+
sessionId,
|
|
932
|
+
connectionMessage: eventData,
|
|
933
|
+
});
|
|
934
|
+
// onOpen already called, but we can log this
|
|
935
|
+
}
|
|
936
|
+
else if (eventData) {
|
|
937
|
+
// Handle regular messages
|
|
938
|
+
try {
|
|
939
|
+
// Skip connection confirmation messages
|
|
940
|
+
if (eventData.startsWith('Connected to topic:')) {
|
|
941
|
+
this.logger.debug('Received connection confirmation', {
|
|
942
|
+
sessionId,
|
|
943
|
+
data: eventData,
|
|
944
|
+
});
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const message = JSON.parse(eventData);
|
|
948
|
+
this.logger.debug('SSE message received', {
|
|
949
|
+
sessionId,
|
|
950
|
+
messageTypes: message.types,
|
|
951
|
+
hasMessage: !!message.message,
|
|
952
|
+
workflowId: message.workflowId,
|
|
953
|
+
});
|
|
954
|
+
callbacks.onMessage?.(message);
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
// If parsing fails, it might be a non-JSON message
|
|
958
|
+
this.logger.debug('SSE message is not JSON', {
|
|
959
|
+
sessionId,
|
|
960
|
+
rawData: eventData,
|
|
961
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch (error) {
|
|
969
|
+
if (abortController?.signal.aborted) {
|
|
970
|
+
// Connection was intentionally closed
|
|
971
|
+
readyState = CLOSED;
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// Connection error
|
|
975
|
+
readyState = CLOSED;
|
|
976
|
+
isConnected = false;
|
|
977
|
+
const errorDetails = {
|
|
978
|
+
sessionId,
|
|
979
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
980
|
+
};
|
|
981
|
+
const isReconnectScheduled = this.reconnectScheduledMap.get(sessionId) ?? false;
|
|
982
|
+
const currentAttempts = this.reconnectAttemptsMap.get(sessionId) ?? 0;
|
|
983
|
+
if (!isReconnectScheduled && currentAttempts < maxAttempts) {
|
|
984
|
+
this.logger.error('SSE connection failed', errorDetails);
|
|
985
|
+
}
|
|
986
|
+
else if (currentAttempts >= maxAttempts) {
|
|
987
|
+
if (!isReconnectScheduled) {
|
|
988
|
+
this.logger.error('SSE connection failed (max attempts reached)', {
|
|
989
|
+
...errorDetails,
|
|
990
|
+
attempts: currentAttempts,
|
|
991
|
+
maxAttempts,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
callbacks.onError?.(error);
|
|
996
|
+
if (!isConnected && !isReconnectScheduled) {
|
|
997
|
+
attemptReconnect();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
// Start the connection asynchronously
|
|
1002
|
+
startConnection();
|
|
1003
|
+
// Return an object that mimics EventSource interface
|
|
1004
|
+
const connectionObject = {
|
|
1005
|
+
url,
|
|
1006
|
+
readyState,
|
|
1007
|
+
close: () => {
|
|
1008
|
+
isClosed = true;
|
|
1009
|
+
readyState = CLOSED;
|
|
1010
|
+
if (abortController) {
|
|
1011
|
+
abortController.abort();
|
|
1012
|
+
}
|
|
1013
|
+
if (reconnectTimeout) {
|
|
1014
|
+
clearTimeout(reconnectTimeout);
|
|
1015
|
+
reconnectTimeout = null;
|
|
1016
|
+
}
|
|
1017
|
+
this.reconnectAttemptsMap.delete(sessionId);
|
|
1018
|
+
this.reconnectScheduledMap.delete(sessionId);
|
|
1019
|
+
this.logger.info('SSE connection closed by client', { sessionId });
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
// Make readyState accessible and updatable
|
|
1023
|
+
Object.defineProperty(connectionObject, 'readyState', {
|
|
1024
|
+
get: () => readyState,
|
|
1025
|
+
enumerable: true,
|
|
1026
|
+
configurable: true,
|
|
1027
|
+
});
|
|
1028
|
+
return connectionObject;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
exports.JivaApiClient = JivaApiClient;
|
|
1032
|
+
//# sourceMappingURL=api.js.map
|