@informedai/react 0.3.0 → 0.4.1

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/index.d.mts CHANGED
@@ -11,6 +11,7 @@ interface Session {
11
11
  taskStates: Record<string, TaskState>;
12
12
  activeTask: string | null;
13
13
  aiConversations: Record<string, ChatMessage[]>;
14
+ status: 'active' | 'ended' | 'abandoned';
14
15
  }
15
16
  interface WidgetMessage {
16
17
  id: string;
@@ -133,6 +134,20 @@ interface SSEEvent {
133
134
  session?: Session;
134
135
  error?: string;
135
136
  }
137
+ /**
138
+ * Response from GET /widget/sessions/:id
139
+ * Includes the session with embedded document and documentType
140
+ */
141
+ interface GetSessionResponse extends Session {
142
+ document: Document & {
143
+ documentType: {
144
+ id: string;
145
+ name: string;
146
+ displayName: string;
147
+ schema: DocumentTypeSchema;
148
+ };
149
+ };
150
+ }
136
151
  interface UseSessionReturn {
137
152
  session: Session | null;
138
153
  isLoading: boolean;
@@ -174,6 +189,7 @@ interface InformedAIContextValue {
174
189
  applyPendingValue: () => Promise<void>;
175
190
  skipTask: () => Promise<void>;
176
191
  startNewSession: () => Promise<void>;
192
+ endSession: () => Promise<void>;
177
193
  clearError: () => void;
178
194
  }
179
195
  declare function useInformedAI(): InformedAIContextValue;
@@ -241,9 +257,9 @@ declare class InformedAIClient {
241
257
  initialData?: Record<string, unknown>;
242
258
  }): Promise<CreateSessionResponse>;
243
259
  /**
244
- * Get an existing session.
260
+ * Get an existing session with its document and documentType.
245
261
  */
246
- getSession(id: string): Promise<Session>;
262
+ getSession(id: string): Promise<GetSessionResponse>;
247
263
  /**
248
264
  * Send a message to the session with SSE streaming.
249
265
  */
@@ -262,6 +278,26 @@ declare class InformedAIClient {
262
278
  * Skip the active task.
263
279
  */
264
280
  skipTask(sessionId: string): Promise<Session>;
281
+ /**
282
+ * Send a heartbeat to keep the session active.
283
+ * Call this every 30 seconds while the session is in use.
284
+ */
285
+ sendHeartbeat(sessionId: string): Promise<void>;
286
+ /**
287
+ * Resume an abandoned session with a new chatbot.
288
+ * Preserves conversation history.
289
+ */
290
+ resumeSession(sessionId: string): Promise<Session>;
291
+ /**
292
+ * Explicitly end a session and release resources.
293
+ * Conversation history is preserved but session cannot be resumed.
294
+ */
295
+ endSession(sessionId: string): Promise<void>;
296
+ /**
297
+ * Send end session via sendBeacon (for beforeunload).
298
+ * Returns true if sendBeacon was used, false if fetch fallback was attempted.
299
+ */
300
+ endSessionBeacon(sessionId: string): boolean;
265
301
  private processSSEStream;
266
302
  }
267
303
 
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ interface Session {
11
11
  taskStates: Record<string, TaskState>;
12
12
  activeTask: string | null;
13
13
  aiConversations: Record<string, ChatMessage[]>;
14
+ status: 'active' | 'ended' | 'abandoned';
14
15
  }
15
16
  interface WidgetMessage {
16
17
  id: string;
@@ -133,6 +134,20 @@ interface SSEEvent {
133
134
  session?: Session;
134
135
  error?: string;
135
136
  }
137
+ /**
138
+ * Response from GET /widget/sessions/:id
139
+ * Includes the session with embedded document and documentType
140
+ */
141
+ interface GetSessionResponse extends Session {
142
+ document: Document & {
143
+ documentType: {
144
+ id: string;
145
+ name: string;
146
+ displayName: string;
147
+ schema: DocumentTypeSchema;
148
+ };
149
+ };
150
+ }
136
151
  interface UseSessionReturn {
137
152
  session: Session | null;
138
153
  isLoading: boolean;
@@ -174,6 +189,7 @@ interface InformedAIContextValue {
174
189
  applyPendingValue: () => Promise<void>;
175
190
  skipTask: () => Promise<void>;
176
191
  startNewSession: () => Promise<void>;
192
+ endSession: () => Promise<void>;
177
193
  clearError: () => void;
178
194
  }
179
195
  declare function useInformedAI(): InformedAIContextValue;
@@ -241,9 +257,9 @@ declare class InformedAIClient {
241
257
  initialData?: Record<string, unknown>;
242
258
  }): Promise<CreateSessionResponse>;
243
259
  /**
244
- * Get an existing session.
260
+ * Get an existing session with its document and documentType.
245
261
  */
246
- getSession(id: string): Promise<Session>;
262
+ getSession(id: string): Promise<GetSessionResponse>;
247
263
  /**
248
264
  * Send a message to the session with SSE streaming.
249
265
  */
@@ -262,6 +278,26 @@ declare class InformedAIClient {
262
278
  * Skip the active task.
263
279
  */
264
280
  skipTask(sessionId: string): Promise<Session>;
281
+ /**
282
+ * Send a heartbeat to keep the session active.
283
+ * Call this every 30 seconds while the session is in use.
284
+ */
285
+ sendHeartbeat(sessionId: string): Promise<void>;
286
+ /**
287
+ * Resume an abandoned session with a new chatbot.
288
+ * Preserves conversation history.
289
+ */
290
+ resumeSession(sessionId: string): Promise<Session>;
291
+ /**
292
+ * Explicitly end a session and release resources.
293
+ * Conversation history is preserved but session cannot be resumed.
294
+ */
295
+ endSession(sessionId: string): Promise<void>;
296
+ /**
297
+ * Send end session via sendBeacon (for beforeunload).
298
+ * Returns true if sendBeacon was used, false if fetch fallback was attempted.
299
+ */
300
+ endSessionBeacon(sessionId: string): boolean;
265
301
  private processSSEStream;
266
302
  }
267
303
 
package/dist/index.js CHANGED
@@ -83,7 +83,7 @@ var InformedAIClient = class {
83
83
  });
84
84
  }
85
85
  /**
86
- * Get an existing session.
86
+ * Get an existing session with its document and documentType.
87
87
  */
88
88
  async getSession(id) {
89
89
  return this.request(`/widget/sessions/${id}`);
@@ -151,6 +151,51 @@ var InformedAIClient = class {
151
151
  method: "POST"
152
152
  });
153
153
  }
154
+ /**
155
+ * Send a heartbeat to keep the session active.
156
+ * Call this every 30 seconds while the session is in use.
157
+ */
158
+ async sendHeartbeat(sessionId) {
159
+ await this.request(`/widget/sessions/${sessionId}/heartbeat`, {
160
+ method: "POST"
161
+ });
162
+ }
163
+ /**
164
+ * Resume an abandoned session with a new chatbot.
165
+ * Preserves conversation history.
166
+ */
167
+ async resumeSession(sessionId) {
168
+ return this.request(`/widget/sessions/${sessionId}/resume`, {
169
+ method: "POST"
170
+ });
171
+ }
172
+ /**
173
+ * Explicitly end a session and release resources.
174
+ * Conversation history is preserved but session cannot be resumed.
175
+ */
176
+ async endSession(sessionId) {
177
+ await this.request(`/widget/sessions/${sessionId}/end`, {
178
+ method: "POST"
179
+ });
180
+ }
181
+ /**
182
+ * Send end session via sendBeacon (for beforeunload).
183
+ * Returns true if sendBeacon was used, false if fetch fallback was attempted.
184
+ */
185
+ endSessionBeacon(sessionId) {
186
+ const url = `${this.apiUrl}/widget/sessions/${sessionId}/end`;
187
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
188
+ const blob = new Blob([JSON.stringify({})], { type: "application/json" });
189
+ return navigator.sendBeacon(url, blob);
190
+ }
191
+ fetch(url, {
192
+ method: "POST",
193
+ headers: this.getHeaders(),
194
+ keepalive: true
195
+ }).catch(() => {
196
+ });
197
+ return false;
198
+ }
154
199
  // ========================================================================
155
200
  // SSE Stream Processing
156
201
  // ========================================================================
@@ -194,6 +239,14 @@ var InformedAIClient = class {
194
239
  // src/context/InformedAIContext.tsx
195
240
  var import_jsx_runtime = require("react/jsx-runtime");
196
241
  var getStorageKey = (documentTypeId, externalId) => `informedai_session_${documentTypeId}${externalId ? `_${externalId}` : ""}`;
242
+ var HEARTBEAT_INTERVAL = 30 * 1e3;
243
+ function isSessionNotFoundError(error) {
244
+ if (error instanceof Error) {
245
+ const message = error.message.toLowerCase();
246
+ return message.includes("session not found") || message.includes("404") || message.includes("not found");
247
+ }
248
+ return false;
249
+ }
197
250
  var InformedAIContext = (0, import_react.createContext)(null);
198
251
  function useInformedAI() {
199
252
  const context = (0, import_react.useContext)(InformedAIContext);
@@ -212,6 +265,8 @@ function InformedAIProvider({ config, children }) {
212
265
  const [streamingContent, setStreamingContent] = (0, import_react.useState)("");
213
266
  const clientRef = (0, import_react.useRef)(null);
214
267
  const initRef = (0, import_react.useRef)(false);
268
+ const heartbeatIntervalRef = (0, import_react.useRef)(null);
269
+ const sessionIdRef = (0, import_react.useRef)(null);
215
270
  const shouldPersist = config.persistSession ?? !!config.externalId;
216
271
  const storageKey = getStorageKey(config.documentTypeId, config.externalId);
217
272
  (0, import_react.useEffect)(() => {
@@ -244,6 +299,45 @@ function InformedAIProvider({ config, children }) {
244
299
  }
245
300
  return { session: result.session, document: result.document, documentType: dt };
246
301
  }, [config.documentTypeId, config.externalId, config.initialData, shouldPersist, storageKey]);
302
+ const clearPersistedSession = (0, import_react.useCallback)(() => {
303
+ if (shouldPersist && typeof window !== "undefined") {
304
+ try {
305
+ localStorage.removeItem(storageKey);
306
+ } catch (e) {
307
+ }
308
+ }
309
+ }, [shouldPersist, storageKey]);
310
+ const handleSessionDeleted = (0, import_react.useCallback)(async () => {
311
+ console.warn("[InformedAI] Session was deleted, starting new session...");
312
+ clearPersistedSession();
313
+ if (heartbeatIntervalRef.current) {
314
+ clearInterval(heartbeatIntervalRef.current);
315
+ heartbeatIntervalRef.current = null;
316
+ }
317
+ sessionIdRef.current = null;
318
+ try {
319
+ const result = await createNewSession();
320
+ if (result) {
321
+ setSession(result.session);
322
+ setDocument(result.document);
323
+ setDocumentType(result.documentType);
324
+ setError(null);
325
+ config.onSessionChange?.(result.session);
326
+ config.onReady?.({
327
+ session: result.session,
328
+ document: result.document,
329
+ documentType: result.documentType
330
+ });
331
+ return true;
332
+ }
333
+ } catch (err) {
334
+ console.error("[InformedAI] Failed to create new session after deletion:", err);
335
+ const error2 = err instanceof Error ? err : new Error("Failed to recover session");
336
+ setError(error2);
337
+ config.onError?.(error2);
338
+ }
339
+ return false;
340
+ }, [createNewSession, clearPersistedSession, config]);
247
341
  (0, import_react.useEffect)(() => {
248
342
  if (initRef.current) return;
249
343
  initRef.current = true;
@@ -264,7 +358,20 @@ function InformedAIProvider({ config, children }) {
264
358
  }
265
359
  if (sessionIdToResume) {
266
360
  try {
267
- sess = await clientRef.current.getSession(sessionIdToResume);
361
+ const resumedSession = await clientRef.current.getSession(sessionIdToResume);
362
+ sess = resumedSession;
363
+ doc = resumedSession.document;
364
+ const embeddedDt = resumedSession.document.documentType;
365
+ dt = {
366
+ id: embeddedDt.id,
367
+ name: embeddedDt.name,
368
+ displayName: embeddedDt.displayName,
369
+ schema: embeddedDt.schema,
370
+ workspaceId: "",
371
+ taskConfigs: {},
372
+ createdAt: "",
373
+ updatedAt: ""
374
+ };
268
375
  } catch (e) {
269
376
  console.warn("Failed to resume session, creating new one");
270
377
  if (shouldPersist && typeof window !== "undefined") {
@@ -282,13 +389,6 @@ function InformedAIProvider({ config, children }) {
282
389
  doc = result.document;
283
390
  dt = result.documentType;
284
391
  }
285
- } else {
286
- const result = await createNewSession();
287
- if (result) {
288
- sess = result.session;
289
- doc = result.document;
290
- dt = result.documentType;
291
- }
292
392
  }
293
393
  if (sess) {
294
394
  setSession(sess);
@@ -309,6 +409,85 @@ function InformedAIProvider({ config, children }) {
309
409
  }
310
410
  initialize();
311
411
  }, [config.documentTypeId, config.sessionId, config.externalId]);
412
+ const startHeartbeat = (0, import_react.useCallback)((sessionId) => {
413
+ if (heartbeatIntervalRef.current) {
414
+ clearInterval(heartbeatIntervalRef.current);
415
+ }
416
+ sessionIdRef.current = sessionId;
417
+ clientRef.current?.sendHeartbeat(sessionId).catch((err) => {
418
+ console.warn("Heartbeat failed:", err);
419
+ });
420
+ heartbeatIntervalRef.current = setInterval(() => {
421
+ if (clientRef.current && sessionIdRef.current) {
422
+ clientRef.current.sendHeartbeat(sessionIdRef.current).catch((err) => {
423
+ console.warn("Heartbeat failed:", err);
424
+ });
425
+ }
426
+ }, HEARTBEAT_INTERVAL);
427
+ }, []);
428
+ const stopHeartbeat = (0, import_react.useCallback)(() => {
429
+ if (heartbeatIntervalRef.current) {
430
+ clearInterval(heartbeatIntervalRef.current);
431
+ heartbeatIntervalRef.current = null;
432
+ }
433
+ }, []);
434
+ (0, import_react.useEffect)(() => {
435
+ if (session?.id && session.status === "active") {
436
+ startHeartbeat(session.id);
437
+ } else {
438
+ stopHeartbeat();
439
+ }
440
+ return () => {
441
+ stopHeartbeat();
442
+ };
443
+ }, [session?.id, session?.status, startHeartbeat, stopHeartbeat]);
444
+ (0, import_react.useEffect)(() => {
445
+ if (typeof window === "undefined") return;
446
+ const handleVisibilityChange = async () => {
447
+ if (window.document.hidden) {
448
+ stopHeartbeat();
449
+ } else if (session?.id && clientRef.current) {
450
+ try {
451
+ const refreshedSession = await clientRef.current.getSession(session.id);
452
+ if (refreshedSession.status === "abandoned") {
453
+ console.log("[InformedAI] Session was abandoned, resuming...");
454
+ try {
455
+ const resumedSession = await clientRef.current.resumeSession(session.id);
456
+ setSession(resumedSession);
457
+ config.onSessionChange?.(resumedSession);
458
+ startHeartbeat(session.id);
459
+ } catch (resumeErr) {
460
+ console.error("[InformedAI] Failed to resume session:", resumeErr);
461
+ await handleSessionDeleted();
462
+ }
463
+ } else if (refreshedSession.status === "active") {
464
+ setSession(refreshedSession);
465
+ startHeartbeat(session.id);
466
+ } else {
467
+ setSession(refreshedSession);
468
+ }
469
+ } catch (err) {
470
+ console.warn("[InformedAI] Session not found on return, creating new...");
471
+ await handleSessionDeleted();
472
+ }
473
+ }
474
+ };
475
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
476
+ return () => {
477
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
478
+ };
479
+ }, [session?.id, startHeartbeat, stopHeartbeat, handleSessionDeleted, config]);
480
+ (0, import_react.useEffect)(() => {
481
+ const handleBeforeUnload = () => {
482
+ if (clientRef.current && sessionIdRef.current) {
483
+ clientRef.current.endSessionBeacon(sessionIdRef.current);
484
+ }
485
+ };
486
+ window.addEventListener("beforeunload", handleBeforeUnload);
487
+ return () => {
488
+ window.removeEventListener("beforeunload", handleBeforeUnload);
489
+ };
490
+ }, []);
312
491
  const handleSSEEvent = (0, import_react.useCallback)((event) => {
313
492
  if (event.type === "content" && event.content) {
314
493
  setStreamingContent((prev) => prev + event.content);
@@ -336,12 +515,17 @@ function InformedAIProvider({ config, children }) {
336
515
  setError(null);
337
516
  await clientRef.current.sendMessage(session.id, message, handleSSEEvent);
338
517
  } catch (err) {
518
+ if (isSessionNotFoundError(err)) {
519
+ setIsStreaming(false);
520
+ await handleSessionDeleted();
521
+ return;
522
+ }
339
523
  const error2 = err instanceof Error ? err : new Error("Failed to send message");
340
524
  setError(error2);
341
525
  config.onError?.(error2);
342
526
  setIsStreaming(false);
343
527
  }
344
- }, [session, handleSSEEvent, config]);
528
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
345
529
  const sendQuickAction = (0, import_react.useCallback)(async (action) => {
346
530
  if (!clientRef.current || !session) return;
347
531
  try {
@@ -356,13 +540,18 @@ function InformedAIProvider({ config, children }) {
356
540
  setSession(newSession);
357
541
  config.onSessionChange?.(newSession);
358
542
  } catch (err) {
543
+ if (isSessionNotFoundError(err)) {
544
+ setIsStreaming(false);
545
+ await handleSessionDeleted();
546
+ return;
547
+ }
359
548
  const error2 = err instanceof Error ? err : new Error("Failed to send quick action");
360
549
  setError(error2);
361
550
  config.onError?.(error2);
362
551
  } finally {
363
552
  setIsStreaming(false);
364
553
  }
365
- }, [session, handleSSEEvent, config]);
554
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
366
555
  const applyPendingValue = (0, import_react.useCallback)(async () => {
367
556
  if (!clientRef.current || !session) return;
368
557
  try {
@@ -372,11 +561,15 @@ function InformedAIProvider({ config, children }) {
372
561
  config.onSessionChange?.(result.session);
373
562
  config.onFieldApply?.(result.appliedField, result.appliedValue);
374
563
  } catch (err) {
564
+ if (isSessionNotFoundError(err)) {
565
+ await handleSessionDeleted();
566
+ return;
567
+ }
375
568
  const error2 = err instanceof Error ? err : new Error("Failed to apply value");
376
569
  setError(error2);
377
570
  config.onError?.(error2);
378
571
  }
379
- }, [session, config]);
572
+ }, [session, handleSessionDeleted, config]);
380
573
  const skipTask = (0, import_react.useCallback)(async () => {
381
574
  if (!clientRef.current || !session) return;
382
575
  try {
@@ -385,22 +578,21 @@ function InformedAIProvider({ config, children }) {
385
578
  setSession(newSession);
386
579
  config.onSessionChange?.(newSession);
387
580
  } catch (err) {
581
+ if (isSessionNotFoundError(err)) {
582
+ await handleSessionDeleted();
583
+ return;
584
+ }
388
585
  const error2 = err instanceof Error ? err : new Error("Failed to skip task");
389
586
  setError(error2);
390
587
  config.onError?.(error2);
391
588
  }
392
- }, [session, config]);
589
+ }, [session, handleSessionDeleted, config]);
393
590
  const startNewSession = (0, import_react.useCallback)(async () => {
394
591
  if (!clientRef.current) return;
395
592
  try {
396
593
  setIsLoading(true);
397
594
  setError(null);
398
- if (shouldPersist && typeof window !== "undefined") {
399
- try {
400
- localStorage.removeItem(storageKey);
401
- } catch (e) {
402
- }
403
- }
595
+ clearPersistedSession();
404
596
  const result = await createNewSession();
405
597
  if (result) {
406
598
  setSession(result.session);
@@ -420,7 +612,21 @@ function InformedAIProvider({ config, children }) {
420
612
  } finally {
421
613
  setIsLoading(false);
422
614
  }
423
- }, [createNewSession, shouldPersist, storageKey, config]);
615
+ }, [createNewSession, clearPersistedSession, config]);
616
+ const endSession = (0, import_react.useCallback)(async () => {
617
+ if (!clientRef.current || !session) return;
618
+ try {
619
+ setError(null);
620
+ stopHeartbeat();
621
+ await clientRef.current.endSession(session.id);
622
+ clearPersistedSession();
623
+ setSession((prev) => prev ? { ...prev, status: "ended" } : null);
624
+ } catch (err) {
625
+ const error2 = err instanceof Error ? err : new Error("Failed to end session");
626
+ setError(error2);
627
+ config.onError?.(error2);
628
+ }
629
+ }, [session, clearPersistedSession, stopHeartbeat, config]);
424
630
  const clearError = (0, import_react.useCallback)(() => {
425
631
  setError(null);
426
632
  }, []);
@@ -437,6 +643,7 @@ function InformedAIProvider({ config, children }) {
437
643
  applyPendingValue,
438
644
  skipTask,
439
645
  startNewSession,
646
+ endSession,
440
647
  clearError
441
648
  };
442
649
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(InformedAIContext.Provider, { value, children });
package/dist/index.mjs CHANGED
@@ -53,7 +53,7 @@ var InformedAIClient = class {
53
53
  });
54
54
  }
55
55
  /**
56
- * Get an existing session.
56
+ * Get an existing session with its document and documentType.
57
57
  */
58
58
  async getSession(id) {
59
59
  return this.request(`/widget/sessions/${id}`);
@@ -121,6 +121,51 @@ var InformedAIClient = class {
121
121
  method: "POST"
122
122
  });
123
123
  }
124
+ /**
125
+ * Send a heartbeat to keep the session active.
126
+ * Call this every 30 seconds while the session is in use.
127
+ */
128
+ async sendHeartbeat(sessionId) {
129
+ await this.request(`/widget/sessions/${sessionId}/heartbeat`, {
130
+ method: "POST"
131
+ });
132
+ }
133
+ /**
134
+ * Resume an abandoned session with a new chatbot.
135
+ * Preserves conversation history.
136
+ */
137
+ async resumeSession(sessionId) {
138
+ return this.request(`/widget/sessions/${sessionId}/resume`, {
139
+ method: "POST"
140
+ });
141
+ }
142
+ /**
143
+ * Explicitly end a session and release resources.
144
+ * Conversation history is preserved but session cannot be resumed.
145
+ */
146
+ async endSession(sessionId) {
147
+ await this.request(`/widget/sessions/${sessionId}/end`, {
148
+ method: "POST"
149
+ });
150
+ }
151
+ /**
152
+ * Send end session via sendBeacon (for beforeunload).
153
+ * Returns true if sendBeacon was used, false if fetch fallback was attempted.
154
+ */
155
+ endSessionBeacon(sessionId) {
156
+ const url = `${this.apiUrl}/widget/sessions/${sessionId}/end`;
157
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
158
+ const blob = new Blob([JSON.stringify({})], { type: "application/json" });
159
+ return navigator.sendBeacon(url, blob);
160
+ }
161
+ fetch(url, {
162
+ method: "POST",
163
+ headers: this.getHeaders(),
164
+ keepalive: true
165
+ }).catch(() => {
166
+ });
167
+ return false;
168
+ }
124
169
  // ========================================================================
125
170
  // SSE Stream Processing
126
171
  // ========================================================================
@@ -164,6 +209,14 @@ var InformedAIClient = class {
164
209
  // src/context/InformedAIContext.tsx
165
210
  import { jsx } from "react/jsx-runtime";
166
211
  var getStorageKey = (documentTypeId, externalId) => `informedai_session_${documentTypeId}${externalId ? `_${externalId}` : ""}`;
212
+ var HEARTBEAT_INTERVAL = 30 * 1e3;
213
+ function isSessionNotFoundError(error) {
214
+ if (error instanceof Error) {
215
+ const message = error.message.toLowerCase();
216
+ return message.includes("session not found") || message.includes("404") || message.includes("not found");
217
+ }
218
+ return false;
219
+ }
167
220
  var InformedAIContext = createContext(null);
168
221
  function useInformedAI() {
169
222
  const context = useContext(InformedAIContext);
@@ -182,6 +235,8 @@ function InformedAIProvider({ config, children }) {
182
235
  const [streamingContent, setStreamingContent] = useState("");
183
236
  const clientRef = useRef(null);
184
237
  const initRef = useRef(false);
238
+ const heartbeatIntervalRef = useRef(null);
239
+ const sessionIdRef = useRef(null);
185
240
  const shouldPersist = config.persistSession ?? !!config.externalId;
186
241
  const storageKey = getStorageKey(config.documentTypeId, config.externalId);
187
242
  useEffect(() => {
@@ -214,6 +269,45 @@ function InformedAIProvider({ config, children }) {
214
269
  }
215
270
  return { session: result.session, document: result.document, documentType: dt };
216
271
  }, [config.documentTypeId, config.externalId, config.initialData, shouldPersist, storageKey]);
272
+ const clearPersistedSession = useCallback(() => {
273
+ if (shouldPersist && typeof window !== "undefined") {
274
+ try {
275
+ localStorage.removeItem(storageKey);
276
+ } catch (e) {
277
+ }
278
+ }
279
+ }, [shouldPersist, storageKey]);
280
+ const handleSessionDeleted = useCallback(async () => {
281
+ console.warn("[InformedAI] Session was deleted, starting new session...");
282
+ clearPersistedSession();
283
+ if (heartbeatIntervalRef.current) {
284
+ clearInterval(heartbeatIntervalRef.current);
285
+ heartbeatIntervalRef.current = null;
286
+ }
287
+ sessionIdRef.current = null;
288
+ try {
289
+ const result = await createNewSession();
290
+ if (result) {
291
+ setSession(result.session);
292
+ setDocument(result.document);
293
+ setDocumentType(result.documentType);
294
+ setError(null);
295
+ config.onSessionChange?.(result.session);
296
+ config.onReady?.({
297
+ session: result.session,
298
+ document: result.document,
299
+ documentType: result.documentType
300
+ });
301
+ return true;
302
+ }
303
+ } catch (err) {
304
+ console.error("[InformedAI] Failed to create new session after deletion:", err);
305
+ const error2 = err instanceof Error ? err : new Error("Failed to recover session");
306
+ setError(error2);
307
+ config.onError?.(error2);
308
+ }
309
+ return false;
310
+ }, [createNewSession, clearPersistedSession, config]);
217
311
  useEffect(() => {
218
312
  if (initRef.current) return;
219
313
  initRef.current = true;
@@ -234,7 +328,20 @@ function InformedAIProvider({ config, children }) {
234
328
  }
235
329
  if (sessionIdToResume) {
236
330
  try {
237
- sess = await clientRef.current.getSession(sessionIdToResume);
331
+ const resumedSession = await clientRef.current.getSession(sessionIdToResume);
332
+ sess = resumedSession;
333
+ doc = resumedSession.document;
334
+ const embeddedDt = resumedSession.document.documentType;
335
+ dt = {
336
+ id: embeddedDt.id,
337
+ name: embeddedDt.name,
338
+ displayName: embeddedDt.displayName,
339
+ schema: embeddedDt.schema,
340
+ workspaceId: "",
341
+ taskConfigs: {},
342
+ createdAt: "",
343
+ updatedAt: ""
344
+ };
238
345
  } catch (e) {
239
346
  console.warn("Failed to resume session, creating new one");
240
347
  if (shouldPersist && typeof window !== "undefined") {
@@ -252,13 +359,6 @@ function InformedAIProvider({ config, children }) {
252
359
  doc = result.document;
253
360
  dt = result.documentType;
254
361
  }
255
- } else {
256
- const result = await createNewSession();
257
- if (result) {
258
- sess = result.session;
259
- doc = result.document;
260
- dt = result.documentType;
261
- }
262
362
  }
263
363
  if (sess) {
264
364
  setSession(sess);
@@ -279,6 +379,85 @@ function InformedAIProvider({ config, children }) {
279
379
  }
280
380
  initialize();
281
381
  }, [config.documentTypeId, config.sessionId, config.externalId]);
382
+ const startHeartbeat = useCallback((sessionId) => {
383
+ if (heartbeatIntervalRef.current) {
384
+ clearInterval(heartbeatIntervalRef.current);
385
+ }
386
+ sessionIdRef.current = sessionId;
387
+ clientRef.current?.sendHeartbeat(sessionId).catch((err) => {
388
+ console.warn("Heartbeat failed:", err);
389
+ });
390
+ heartbeatIntervalRef.current = setInterval(() => {
391
+ if (clientRef.current && sessionIdRef.current) {
392
+ clientRef.current.sendHeartbeat(sessionIdRef.current).catch((err) => {
393
+ console.warn("Heartbeat failed:", err);
394
+ });
395
+ }
396
+ }, HEARTBEAT_INTERVAL);
397
+ }, []);
398
+ const stopHeartbeat = useCallback(() => {
399
+ if (heartbeatIntervalRef.current) {
400
+ clearInterval(heartbeatIntervalRef.current);
401
+ heartbeatIntervalRef.current = null;
402
+ }
403
+ }, []);
404
+ useEffect(() => {
405
+ if (session?.id && session.status === "active") {
406
+ startHeartbeat(session.id);
407
+ } else {
408
+ stopHeartbeat();
409
+ }
410
+ return () => {
411
+ stopHeartbeat();
412
+ };
413
+ }, [session?.id, session?.status, startHeartbeat, stopHeartbeat]);
414
+ useEffect(() => {
415
+ if (typeof window === "undefined") return;
416
+ const handleVisibilityChange = async () => {
417
+ if (window.document.hidden) {
418
+ stopHeartbeat();
419
+ } else if (session?.id && clientRef.current) {
420
+ try {
421
+ const refreshedSession = await clientRef.current.getSession(session.id);
422
+ if (refreshedSession.status === "abandoned") {
423
+ console.log("[InformedAI] Session was abandoned, resuming...");
424
+ try {
425
+ const resumedSession = await clientRef.current.resumeSession(session.id);
426
+ setSession(resumedSession);
427
+ config.onSessionChange?.(resumedSession);
428
+ startHeartbeat(session.id);
429
+ } catch (resumeErr) {
430
+ console.error("[InformedAI] Failed to resume session:", resumeErr);
431
+ await handleSessionDeleted();
432
+ }
433
+ } else if (refreshedSession.status === "active") {
434
+ setSession(refreshedSession);
435
+ startHeartbeat(session.id);
436
+ } else {
437
+ setSession(refreshedSession);
438
+ }
439
+ } catch (err) {
440
+ console.warn("[InformedAI] Session not found on return, creating new...");
441
+ await handleSessionDeleted();
442
+ }
443
+ }
444
+ };
445
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
446
+ return () => {
447
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
448
+ };
449
+ }, [session?.id, startHeartbeat, stopHeartbeat, handleSessionDeleted, config]);
450
+ useEffect(() => {
451
+ const handleBeforeUnload = () => {
452
+ if (clientRef.current && sessionIdRef.current) {
453
+ clientRef.current.endSessionBeacon(sessionIdRef.current);
454
+ }
455
+ };
456
+ window.addEventListener("beforeunload", handleBeforeUnload);
457
+ return () => {
458
+ window.removeEventListener("beforeunload", handleBeforeUnload);
459
+ };
460
+ }, []);
282
461
  const handleSSEEvent = useCallback((event) => {
283
462
  if (event.type === "content" && event.content) {
284
463
  setStreamingContent((prev) => prev + event.content);
@@ -306,12 +485,17 @@ function InformedAIProvider({ config, children }) {
306
485
  setError(null);
307
486
  await clientRef.current.sendMessage(session.id, message, handleSSEEvent);
308
487
  } catch (err) {
488
+ if (isSessionNotFoundError(err)) {
489
+ setIsStreaming(false);
490
+ await handleSessionDeleted();
491
+ return;
492
+ }
309
493
  const error2 = err instanceof Error ? err : new Error("Failed to send message");
310
494
  setError(error2);
311
495
  config.onError?.(error2);
312
496
  setIsStreaming(false);
313
497
  }
314
- }, [session, handleSSEEvent, config]);
498
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
315
499
  const sendQuickAction = useCallback(async (action) => {
316
500
  if (!clientRef.current || !session) return;
317
501
  try {
@@ -326,13 +510,18 @@ function InformedAIProvider({ config, children }) {
326
510
  setSession(newSession);
327
511
  config.onSessionChange?.(newSession);
328
512
  } catch (err) {
513
+ if (isSessionNotFoundError(err)) {
514
+ setIsStreaming(false);
515
+ await handleSessionDeleted();
516
+ return;
517
+ }
329
518
  const error2 = err instanceof Error ? err : new Error("Failed to send quick action");
330
519
  setError(error2);
331
520
  config.onError?.(error2);
332
521
  } finally {
333
522
  setIsStreaming(false);
334
523
  }
335
- }, [session, handleSSEEvent, config]);
524
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
336
525
  const applyPendingValue = useCallback(async () => {
337
526
  if (!clientRef.current || !session) return;
338
527
  try {
@@ -342,11 +531,15 @@ function InformedAIProvider({ config, children }) {
342
531
  config.onSessionChange?.(result.session);
343
532
  config.onFieldApply?.(result.appliedField, result.appliedValue);
344
533
  } catch (err) {
534
+ if (isSessionNotFoundError(err)) {
535
+ await handleSessionDeleted();
536
+ return;
537
+ }
345
538
  const error2 = err instanceof Error ? err : new Error("Failed to apply value");
346
539
  setError(error2);
347
540
  config.onError?.(error2);
348
541
  }
349
- }, [session, config]);
542
+ }, [session, handleSessionDeleted, config]);
350
543
  const skipTask = useCallback(async () => {
351
544
  if (!clientRef.current || !session) return;
352
545
  try {
@@ -355,22 +548,21 @@ function InformedAIProvider({ config, children }) {
355
548
  setSession(newSession);
356
549
  config.onSessionChange?.(newSession);
357
550
  } catch (err) {
551
+ if (isSessionNotFoundError(err)) {
552
+ await handleSessionDeleted();
553
+ return;
554
+ }
358
555
  const error2 = err instanceof Error ? err : new Error("Failed to skip task");
359
556
  setError(error2);
360
557
  config.onError?.(error2);
361
558
  }
362
- }, [session, config]);
559
+ }, [session, handleSessionDeleted, config]);
363
560
  const startNewSession = useCallback(async () => {
364
561
  if (!clientRef.current) return;
365
562
  try {
366
563
  setIsLoading(true);
367
564
  setError(null);
368
- if (shouldPersist && typeof window !== "undefined") {
369
- try {
370
- localStorage.removeItem(storageKey);
371
- } catch (e) {
372
- }
373
- }
565
+ clearPersistedSession();
374
566
  const result = await createNewSession();
375
567
  if (result) {
376
568
  setSession(result.session);
@@ -390,7 +582,21 @@ function InformedAIProvider({ config, children }) {
390
582
  } finally {
391
583
  setIsLoading(false);
392
584
  }
393
- }, [createNewSession, shouldPersist, storageKey, config]);
585
+ }, [createNewSession, clearPersistedSession, config]);
586
+ const endSession = useCallback(async () => {
587
+ if (!clientRef.current || !session) return;
588
+ try {
589
+ setError(null);
590
+ stopHeartbeat();
591
+ await clientRef.current.endSession(session.id);
592
+ clearPersistedSession();
593
+ setSession((prev) => prev ? { ...prev, status: "ended" } : null);
594
+ } catch (err) {
595
+ const error2 = err instanceof Error ? err : new Error("Failed to end session");
596
+ setError(error2);
597
+ config.onError?.(error2);
598
+ }
599
+ }, [session, clearPersistedSession, stopHeartbeat, config]);
394
600
  const clearError = useCallback(() => {
395
601
  setError(null);
396
602
  }, []);
@@ -407,6 +613,7 @@ function InformedAIProvider({ config, children }) {
407
613
  applyPendingValue,
408
614
  skipTask,
409
615
  startNewSession,
616
+ endSession,
410
617
  clearError
411
618
  };
412
619
  return /* @__PURE__ */ jsx(InformedAIContext.Provider, { value, children });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@informedai/react",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "React SDK for InformedAI Assistant - AI-powered content creation widget",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",