@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/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