@informedai/react 0.2.6 → 0.4.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/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;
@@ -76,7 +77,24 @@ interface InformedAssistantConfig {
76
77
  documentTypeId: string;
77
78
  /** API base URL (defaults to https://api.informedassistant.ai/api/v1) */
78
79
  apiUrl?: string;
79
- /** Optional: Existing session ID to resume */
80
+ /**
81
+ * External ID for idempotency - your object's ID in your system.
82
+ * If provided, the widget will find or create a document linked to this ID.
83
+ * This allows resuming work on the same document across sessions.
84
+ */
85
+ externalId?: string;
86
+ /**
87
+ * Your current field values to sync before starting.
88
+ * Since you're the source of truth, this data will be written to our document.
89
+ * Use this to ensure the assistant sees your latest data.
90
+ */
91
+ initialData?: Record<string, unknown>;
92
+ /**
93
+ * Whether to persist sessions in localStorage for resumption.
94
+ * Defaults to true when externalId is provided, false otherwise.
95
+ */
96
+ persistSession?: boolean;
97
+ /** Optional: Existing session ID to resume (overrides localStorage) */
80
98
  sessionId?: string;
81
99
  /** Callback when widget is ready with document type schema */
82
100
  onReady?: (context: WidgetReadyContext) => void;
@@ -116,6 +134,20 @@ interface SSEEvent {
116
134
  session?: Session;
117
135
  error?: string;
118
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
+ }
119
151
  interface UseSessionReturn {
120
152
  session: Session | null;
121
153
  isLoading: boolean;
@@ -156,6 +188,8 @@ interface InformedAIContextValue {
156
188
  sendQuickAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
157
189
  applyPendingValue: () => Promise<void>;
158
190
  skipTask: () => Promise<void>;
191
+ startNewSession: () => Promise<void>;
192
+ endSession: () => Promise<void>;
159
193
  clearError: () => void;
160
194
  }
161
195
  declare function useInformedAI(): InformedAIContextValue;
@@ -213,13 +247,19 @@ declare class InformedAIClient {
213
247
  private request;
214
248
  /**
215
249
  * Create a new session for a document type.
216
- * This automatically creates a document with default values.
250
+ *
251
+ * @param documentTypeId - The document type to create a session for
252
+ * @param options.externalId - Your object's ID for idempotency (finds or creates document)
253
+ * @param options.initialData - Your current field values to sync (you're the source of truth)
217
254
  */
218
- createSession(documentTypeId: string): Promise<CreateSessionResponse>;
255
+ createSession(documentTypeId: string, options?: {
256
+ externalId?: string;
257
+ initialData?: Record<string, unknown>;
258
+ }): Promise<CreateSessionResponse>;
219
259
  /**
220
- * Get an existing session.
260
+ * Get an existing session with its document and documentType.
221
261
  */
222
- getSession(id: string): Promise<Session>;
262
+ getSession(id: string): Promise<GetSessionResponse>;
223
263
  /**
224
264
  * Send a message to the session with SSE streaming.
225
265
  */
@@ -238,6 +278,26 @@ declare class InformedAIClient {
238
278
  * Skip the active task.
239
279
  */
240
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;
241
301
  private processSSEStream;
242
302
  }
243
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;
@@ -76,7 +77,24 @@ interface InformedAssistantConfig {
76
77
  documentTypeId: string;
77
78
  /** API base URL (defaults to https://api.informedassistant.ai/api/v1) */
78
79
  apiUrl?: string;
79
- /** Optional: Existing session ID to resume */
80
+ /**
81
+ * External ID for idempotency - your object's ID in your system.
82
+ * If provided, the widget will find or create a document linked to this ID.
83
+ * This allows resuming work on the same document across sessions.
84
+ */
85
+ externalId?: string;
86
+ /**
87
+ * Your current field values to sync before starting.
88
+ * Since you're the source of truth, this data will be written to our document.
89
+ * Use this to ensure the assistant sees your latest data.
90
+ */
91
+ initialData?: Record<string, unknown>;
92
+ /**
93
+ * Whether to persist sessions in localStorage for resumption.
94
+ * Defaults to true when externalId is provided, false otherwise.
95
+ */
96
+ persistSession?: boolean;
97
+ /** Optional: Existing session ID to resume (overrides localStorage) */
80
98
  sessionId?: string;
81
99
  /** Callback when widget is ready with document type schema */
82
100
  onReady?: (context: WidgetReadyContext) => void;
@@ -116,6 +134,20 @@ interface SSEEvent {
116
134
  session?: Session;
117
135
  error?: string;
118
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
+ }
119
151
  interface UseSessionReturn {
120
152
  session: Session | null;
121
153
  isLoading: boolean;
@@ -156,6 +188,8 @@ interface InformedAIContextValue {
156
188
  sendQuickAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
157
189
  applyPendingValue: () => Promise<void>;
158
190
  skipTask: () => Promise<void>;
191
+ startNewSession: () => Promise<void>;
192
+ endSession: () => Promise<void>;
159
193
  clearError: () => void;
160
194
  }
161
195
  declare function useInformedAI(): InformedAIContextValue;
@@ -213,13 +247,19 @@ declare class InformedAIClient {
213
247
  private request;
214
248
  /**
215
249
  * Create a new session for a document type.
216
- * This automatically creates a document with default values.
250
+ *
251
+ * @param documentTypeId - The document type to create a session for
252
+ * @param options.externalId - Your object's ID for idempotency (finds or creates document)
253
+ * @param options.initialData - Your current field values to sync (you're the source of truth)
217
254
  */
218
- createSession(documentTypeId: string): Promise<CreateSessionResponse>;
255
+ createSession(documentTypeId: string, options?: {
256
+ externalId?: string;
257
+ initialData?: Record<string, unknown>;
258
+ }): Promise<CreateSessionResponse>;
219
259
  /**
220
- * Get an existing session.
260
+ * Get an existing session with its document and documentType.
221
261
  */
222
- getSession(id: string): Promise<Session>;
262
+ getSession(id: string): Promise<GetSessionResponse>;
223
263
  /**
224
264
  * Send a message to the session with SSE streaming.
225
265
  */
@@ -238,6 +278,26 @@ declare class InformedAIClient {
238
278
  * Skip the active task.
239
279
  */
240
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;
241
301
  private processSSEStream;
242
302
  }
243
303
 
package/dist/index.js CHANGED
@@ -67,16 +67,23 @@ var InformedAIClient = class {
67
67
  // ========================================================================
68
68
  /**
69
69
  * Create a new session for a document type.
70
- * This automatically creates a document with default values.
70
+ *
71
+ * @param documentTypeId - The document type to create a session for
72
+ * @param options.externalId - Your object's ID for idempotency (finds or creates document)
73
+ * @param options.initialData - Your current field values to sync (you're the source of truth)
71
74
  */
72
- async createSession(documentTypeId) {
75
+ async createSession(documentTypeId, options) {
73
76
  return this.request("/widget/sessions", {
74
77
  method: "POST",
75
- body: JSON.stringify({ documentTypeId })
78
+ body: JSON.stringify({
79
+ documentTypeId,
80
+ externalId: options?.externalId,
81
+ initialData: options?.initialData
82
+ })
76
83
  });
77
84
  }
78
85
  /**
79
- * Get an existing session.
86
+ * Get an existing session with its document and documentType.
80
87
  */
81
88
  async getSession(id) {
82
89
  return this.request(`/widget/sessions/${id}`);
@@ -144,6 +151,51 @@ var InformedAIClient = class {
144
151
  method: "POST"
145
152
  });
146
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
+ }
147
199
  // ========================================================================
148
200
  // SSE Stream Processing
149
201
  // ========================================================================
@@ -186,6 +238,15 @@ var InformedAIClient = class {
186
238
 
187
239
  // src/context/InformedAIContext.tsx
188
240
  var import_jsx_runtime = require("react/jsx-runtime");
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
+ }
189
250
  var InformedAIContext = (0, import_react.createContext)(null);
190
251
  function useInformedAI() {
191
252
  const context = (0, import_react.useContext)(InformedAIContext);
@@ -203,41 +264,140 @@ function InformedAIProvider({ config, children }) {
203
264
  const [error, setError] = (0, import_react.useState)(null);
204
265
  const [streamingContent, setStreamingContent] = (0, import_react.useState)("");
205
266
  const clientRef = (0, import_react.useRef)(null);
267
+ const initRef = (0, import_react.useRef)(false);
268
+ const heartbeatIntervalRef = (0, import_react.useRef)(null);
269
+ const sessionIdRef = (0, import_react.useRef)(null);
270
+ const shouldPersist = config.persistSession ?? !!config.externalId;
271
+ const storageKey = getStorageKey(config.documentTypeId, config.externalId);
206
272
  (0, import_react.useEffect)(() => {
207
273
  clientRef.current = new InformedAIClient(config.apiUrl);
208
274
  }, [config.apiUrl]);
275
+ const createNewSession = (0, import_react.useCallback)(async () => {
276
+ if (!clientRef.current) return null;
277
+ const result = await clientRef.current.createSession(
278
+ config.documentTypeId,
279
+ {
280
+ externalId: config.externalId,
281
+ initialData: config.initialData
282
+ }
283
+ );
284
+ const dt = {
285
+ id: result.documentType.id,
286
+ name: result.documentType.name,
287
+ displayName: result.documentType.displayName,
288
+ schema: result.documentType.schema,
289
+ workspaceId: "",
290
+ taskConfigs: {},
291
+ createdAt: "",
292
+ updatedAt: ""
293
+ };
294
+ if (shouldPersist && typeof window !== "undefined") {
295
+ try {
296
+ localStorage.setItem(storageKey, result.session.id);
297
+ } catch (e) {
298
+ }
299
+ }
300
+ return { session: result.session, document: result.document, documentType: dt };
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]);
209
341
  (0, import_react.useEffect)(() => {
342
+ if (initRef.current) return;
343
+ initRef.current = true;
210
344
  async function initialize() {
211
345
  if (!clientRef.current) return;
212
346
  try {
213
347
  setIsLoading(true);
214
348
  setError(null);
215
- let sess;
349
+ let sess = null;
216
350
  let doc = null;
217
351
  let dt = null;
218
- if (config.sessionId) {
219
- sess = await clientRef.current.getSession(config.sessionId);
220
- } else {
221
- const result = await clientRef.current.createSession(config.documentTypeId);
222
- sess = result.session;
223
- doc = result.document;
224
- dt = {
225
- id: result.documentType.id,
226
- name: result.documentType.name,
227
- displayName: result.documentType.displayName,
228
- schema: result.documentType.schema,
229
- workspaceId: "",
230
- taskConfigs: {},
231
- createdAt: "",
232
- updatedAt: ""
233
- };
352
+ let sessionIdToResume = config.sessionId;
353
+ if (!sessionIdToResume && shouldPersist && typeof window !== "undefined") {
354
+ try {
355
+ sessionIdToResume = localStorage.getItem(storageKey) || void 0;
356
+ } catch (e) {
357
+ }
234
358
  }
235
- setSession(sess);
236
- if (doc) setDocument(doc);
237
- if (dt) setDocumentType(dt);
238
- config.onSessionChange?.(sess);
239
- if (doc && dt) {
240
- config.onReady?.({ session: sess, document: doc, documentType: dt });
359
+ if (sessionIdToResume) {
360
+ try {
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
+ };
375
+ } catch (e) {
376
+ console.warn("Failed to resume session, creating new one");
377
+ if (shouldPersist && typeof window !== "undefined") {
378
+ try {
379
+ localStorage.removeItem(storageKey);
380
+ } catch (e2) {
381
+ }
382
+ }
383
+ }
384
+ }
385
+ if (!sess) {
386
+ const result = await createNewSession();
387
+ if (result) {
388
+ sess = result.session;
389
+ doc = result.document;
390
+ dt = result.documentType;
391
+ }
392
+ }
393
+ if (sess) {
394
+ setSession(sess);
395
+ if (doc) setDocument(doc);
396
+ if (dt) setDocumentType(dt);
397
+ config.onSessionChange?.(sess);
398
+ if (doc && dt) {
399
+ config.onReady?.({ session: sess, document: doc, documentType: dt });
400
+ }
241
401
  }
242
402
  } catch (err) {
243
403
  const error2 = err instanceof Error ? err : new Error("Initialization failed");
@@ -248,7 +408,64 @@ function InformedAIProvider({ config, children }) {
248
408
  }
249
409
  }
250
410
  initialize();
251
- }, [config.documentTypeId, config.sessionId]);
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 = () => {
447
+ if (window.document.hidden) {
448
+ stopHeartbeat();
449
+ } else if (session?.id && session.status === "active") {
450
+ startHeartbeat(session.id);
451
+ }
452
+ };
453
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
454
+ return () => {
455
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
456
+ };
457
+ }, [session?.id, session?.status, startHeartbeat, stopHeartbeat]);
458
+ (0, import_react.useEffect)(() => {
459
+ const handleBeforeUnload = () => {
460
+ if (clientRef.current && sessionIdRef.current) {
461
+ clientRef.current.endSessionBeacon(sessionIdRef.current);
462
+ }
463
+ };
464
+ window.addEventListener("beforeunload", handleBeforeUnload);
465
+ return () => {
466
+ window.removeEventListener("beforeunload", handleBeforeUnload);
467
+ };
468
+ }, []);
252
469
  const handleSSEEvent = (0, import_react.useCallback)((event) => {
253
470
  if (event.type === "content" && event.content) {
254
471
  setStreamingContent((prev) => prev + event.content);
@@ -276,12 +493,17 @@ function InformedAIProvider({ config, children }) {
276
493
  setError(null);
277
494
  await clientRef.current.sendMessage(session.id, message, handleSSEEvent);
278
495
  } catch (err) {
496
+ if (isSessionNotFoundError(err)) {
497
+ setIsStreaming(false);
498
+ await handleSessionDeleted();
499
+ return;
500
+ }
279
501
  const error2 = err instanceof Error ? err : new Error("Failed to send message");
280
502
  setError(error2);
281
503
  config.onError?.(error2);
282
504
  setIsStreaming(false);
283
505
  }
284
- }, [session, handleSSEEvent, config]);
506
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
285
507
  const sendQuickAction = (0, import_react.useCallback)(async (action) => {
286
508
  if (!clientRef.current || !session) return;
287
509
  try {
@@ -296,13 +518,18 @@ function InformedAIProvider({ config, children }) {
296
518
  setSession(newSession);
297
519
  config.onSessionChange?.(newSession);
298
520
  } catch (err) {
521
+ if (isSessionNotFoundError(err)) {
522
+ setIsStreaming(false);
523
+ await handleSessionDeleted();
524
+ return;
525
+ }
299
526
  const error2 = err instanceof Error ? err : new Error("Failed to send quick action");
300
527
  setError(error2);
301
528
  config.onError?.(error2);
302
529
  } finally {
303
530
  setIsStreaming(false);
304
531
  }
305
- }, [session, handleSSEEvent, config]);
532
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
306
533
  const applyPendingValue = (0, import_react.useCallback)(async () => {
307
534
  if (!clientRef.current || !session) return;
308
535
  try {
@@ -312,11 +539,15 @@ function InformedAIProvider({ config, children }) {
312
539
  config.onSessionChange?.(result.session);
313
540
  config.onFieldApply?.(result.appliedField, result.appliedValue);
314
541
  } catch (err) {
542
+ if (isSessionNotFoundError(err)) {
543
+ await handleSessionDeleted();
544
+ return;
545
+ }
315
546
  const error2 = err instanceof Error ? err : new Error("Failed to apply value");
316
547
  setError(error2);
317
548
  config.onError?.(error2);
318
549
  }
319
- }, [session, config]);
550
+ }, [session, handleSessionDeleted, config]);
320
551
  const skipTask = (0, import_react.useCallback)(async () => {
321
552
  if (!clientRef.current || !session) return;
322
553
  try {
@@ -325,11 +556,55 @@ function InformedAIProvider({ config, children }) {
325
556
  setSession(newSession);
326
557
  config.onSessionChange?.(newSession);
327
558
  } catch (err) {
559
+ if (isSessionNotFoundError(err)) {
560
+ await handleSessionDeleted();
561
+ return;
562
+ }
328
563
  const error2 = err instanceof Error ? err : new Error("Failed to skip task");
329
564
  setError(error2);
330
565
  config.onError?.(error2);
331
566
  }
332
- }, [session, config]);
567
+ }, [session, handleSessionDeleted, config]);
568
+ const startNewSession = (0, import_react.useCallback)(async () => {
569
+ if (!clientRef.current) return;
570
+ try {
571
+ setIsLoading(true);
572
+ setError(null);
573
+ clearPersistedSession();
574
+ const result = await createNewSession();
575
+ if (result) {
576
+ setSession(result.session);
577
+ setDocument(result.document);
578
+ setDocumentType(result.documentType);
579
+ config.onSessionChange?.(result.session);
580
+ config.onReady?.({
581
+ session: result.session,
582
+ document: result.document,
583
+ documentType: result.documentType
584
+ });
585
+ }
586
+ } catch (err) {
587
+ const error2 = err instanceof Error ? err : new Error("Failed to start new session");
588
+ setError(error2);
589
+ config.onError?.(error2);
590
+ } finally {
591
+ setIsLoading(false);
592
+ }
593
+ }, [createNewSession, clearPersistedSession, config]);
594
+ const endSession = (0, import_react.useCallback)(async () => {
595
+ if (!clientRef.current || !session) return;
596
+ try {
597
+ setError(null);
598
+ stopHeartbeat();
599
+ await clientRef.current.endSession(session.id);
600
+ clearPersistedSession();
601
+ setSession((prev) => prev ? { ...prev, status: "ended" } : null);
602
+ } catch (err) {
603
+ const error2 = err instanceof Error ? err : new Error("Failed to end session");
604
+ setError(error2);
605
+ config.onError?.(error2);
606
+ }
607
+ }, [session, clearPersistedSession, stopHeartbeat, config]);
333
608
  const clearError = (0, import_react.useCallback)(() => {
334
609
  setError(null);
335
610
  }, []);
@@ -345,6 +620,8 @@ function InformedAIProvider({ config, children }) {
345
620
  sendQuickAction,
346
621
  applyPendingValue,
347
622
  skipTask,
623
+ startNewSession,
624
+ endSession,
348
625
  clearError
349
626
  };
350
627
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(InformedAIContext.Provider, { value, children });
package/dist/index.mjs CHANGED
@@ -37,16 +37,23 @@ var InformedAIClient = class {
37
37
  // ========================================================================
38
38
  /**
39
39
  * Create a new session for a document type.
40
- * This automatically creates a document with default values.
40
+ *
41
+ * @param documentTypeId - The document type to create a session for
42
+ * @param options.externalId - Your object's ID for idempotency (finds or creates document)
43
+ * @param options.initialData - Your current field values to sync (you're the source of truth)
41
44
  */
42
- async createSession(documentTypeId) {
45
+ async createSession(documentTypeId, options) {
43
46
  return this.request("/widget/sessions", {
44
47
  method: "POST",
45
- body: JSON.stringify({ documentTypeId })
48
+ body: JSON.stringify({
49
+ documentTypeId,
50
+ externalId: options?.externalId,
51
+ initialData: options?.initialData
52
+ })
46
53
  });
47
54
  }
48
55
  /**
49
- * Get an existing session.
56
+ * Get an existing session with its document and documentType.
50
57
  */
51
58
  async getSession(id) {
52
59
  return this.request(`/widget/sessions/${id}`);
@@ -114,6 +121,51 @@ var InformedAIClient = class {
114
121
  method: "POST"
115
122
  });
116
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
+ }
117
169
  // ========================================================================
118
170
  // SSE Stream Processing
119
171
  // ========================================================================
@@ -156,6 +208,15 @@ var InformedAIClient = class {
156
208
 
157
209
  // src/context/InformedAIContext.tsx
158
210
  import { jsx } from "react/jsx-runtime";
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
+ }
159
220
  var InformedAIContext = createContext(null);
160
221
  function useInformedAI() {
161
222
  const context = useContext(InformedAIContext);
@@ -173,41 +234,140 @@ function InformedAIProvider({ config, children }) {
173
234
  const [error, setError] = useState(null);
174
235
  const [streamingContent, setStreamingContent] = useState("");
175
236
  const clientRef = useRef(null);
237
+ const initRef = useRef(false);
238
+ const heartbeatIntervalRef = useRef(null);
239
+ const sessionIdRef = useRef(null);
240
+ const shouldPersist = config.persistSession ?? !!config.externalId;
241
+ const storageKey = getStorageKey(config.documentTypeId, config.externalId);
176
242
  useEffect(() => {
177
243
  clientRef.current = new InformedAIClient(config.apiUrl);
178
244
  }, [config.apiUrl]);
245
+ const createNewSession = useCallback(async () => {
246
+ if (!clientRef.current) return null;
247
+ const result = await clientRef.current.createSession(
248
+ config.documentTypeId,
249
+ {
250
+ externalId: config.externalId,
251
+ initialData: config.initialData
252
+ }
253
+ );
254
+ const dt = {
255
+ id: result.documentType.id,
256
+ name: result.documentType.name,
257
+ displayName: result.documentType.displayName,
258
+ schema: result.documentType.schema,
259
+ workspaceId: "",
260
+ taskConfigs: {},
261
+ createdAt: "",
262
+ updatedAt: ""
263
+ };
264
+ if (shouldPersist && typeof window !== "undefined") {
265
+ try {
266
+ localStorage.setItem(storageKey, result.session.id);
267
+ } catch (e) {
268
+ }
269
+ }
270
+ return { session: result.session, document: result.document, documentType: dt };
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]);
179
311
  useEffect(() => {
312
+ if (initRef.current) return;
313
+ initRef.current = true;
180
314
  async function initialize() {
181
315
  if (!clientRef.current) return;
182
316
  try {
183
317
  setIsLoading(true);
184
318
  setError(null);
185
- let sess;
319
+ let sess = null;
186
320
  let doc = null;
187
321
  let dt = null;
188
- if (config.sessionId) {
189
- sess = await clientRef.current.getSession(config.sessionId);
190
- } else {
191
- const result = await clientRef.current.createSession(config.documentTypeId);
192
- sess = result.session;
193
- doc = result.document;
194
- dt = {
195
- id: result.documentType.id,
196
- name: result.documentType.name,
197
- displayName: result.documentType.displayName,
198
- schema: result.documentType.schema,
199
- workspaceId: "",
200
- taskConfigs: {},
201
- createdAt: "",
202
- updatedAt: ""
203
- };
322
+ let sessionIdToResume = config.sessionId;
323
+ if (!sessionIdToResume && shouldPersist && typeof window !== "undefined") {
324
+ try {
325
+ sessionIdToResume = localStorage.getItem(storageKey) || void 0;
326
+ } catch (e) {
327
+ }
204
328
  }
205
- setSession(sess);
206
- if (doc) setDocument(doc);
207
- if (dt) setDocumentType(dt);
208
- config.onSessionChange?.(sess);
209
- if (doc && dt) {
210
- config.onReady?.({ session: sess, document: doc, documentType: dt });
329
+ if (sessionIdToResume) {
330
+ try {
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
+ };
345
+ } catch (e) {
346
+ console.warn("Failed to resume session, creating new one");
347
+ if (shouldPersist && typeof window !== "undefined") {
348
+ try {
349
+ localStorage.removeItem(storageKey);
350
+ } catch (e2) {
351
+ }
352
+ }
353
+ }
354
+ }
355
+ if (!sess) {
356
+ const result = await createNewSession();
357
+ if (result) {
358
+ sess = result.session;
359
+ doc = result.document;
360
+ dt = result.documentType;
361
+ }
362
+ }
363
+ if (sess) {
364
+ setSession(sess);
365
+ if (doc) setDocument(doc);
366
+ if (dt) setDocumentType(dt);
367
+ config.onSessionChange?.(sess);
368
+ if (doc && dt) {
369
+ config.onReady?.({ session: sess, document: doc, documentType: dt });
370
+ }
211
371
  }
212
372
  } catch (err) {
213
373
  const error2 = err instanceof Error ? err : new Error("Initialization failed");
@@ -218,7 +378,64 @@ function InformedAIProvider({ config, children }) {
218
378
  }
219
379
  }
220
380
  initialize();
221
- }, [config.documentTypeId, config.sessionId]);
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 = () => {
417
+ if (window.document.hidden) {
418
+ stopHeartbeat();
419
+ } else if (session?.id && session.status === "active") {
420
+ startHeartbeat(session.id);
421
+ }
422
+ };
423
+ window.document.addEventListener("visibilitychange", handleVisibilityChange);
424
+ return () => {
425
+ window.document.removeEventListener("visibilitychange", handleVisibilityChange);
426
+ };
427
+ }, [session?.id, session?.status, startHeartbeat, stopHeartbeat]);
428
+ useEffect(() => {
429
+ const handleBeforeUnload = () => {
430
+ if (clientRef.current && sessionIdRef.current) {
431
+ clientRef.current.endSessionBeacon(sessionIdRef.current);
432
+ }
433
+ };
434
+ window.addEventListener("beforeunload", handleBeforeUnload);
435
+ return () => {
436
+ window.removeEventListener("beforeunload", handleBeforeUnload);
437
+ };
438
+ }, []);
222
439
  const handleSSEEvent = useCallback((event) => {
223
440
  if (event.type === "content" && event.content) {
224
441
  setStreamingContent((prev) => prev + event.content);
@@ -246,12 +463,17 @@ function InformedAIProvider({ config, children }) {
246
463
  setError(null);
247
464
  await clientRef.current.sendMessage(session.id, message, handleSSEEvent);
248
465
  } catch (err) {
466
+ if (isSessionNotFoundError(err)) {
467
+ setIsStreaming(false);
468
+ await handleSessionDeleted();
469
+ return;
470
+ }
249
471
  const error2 = err instanceof Error ? err : new Error("Failed to send message");
250
472
  setError(error2);
251
473
  config.onError?.(error2);
252
474
  setIsStreaming(false);
253
475
  }
254
- }, [session, handleSSEEvent, config]);
476
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
255
477
  const sendQuickAction = useCallback(async (action) => {
256
478
  if (!clientRef.current || !session) return;
257
479
  try {
@@ -266,13 +488,18 @@ function InformedAIProvider({ config, children }) {
266
488
  setSession(newSession);
267
489
  config.onSessionChange?.(newSession);
268
490
  } catch (err) {
491
+ if (isSessionNotFoundError(err)) {
492
+ setIsStreaming(false);
493
+ await handleSessionDeleted();
494
+ return;
495
+ }
269
496
  const error2 = err instanceof Error ? err : new Error("Failed to send quick action");
270
497
  setError(error2);
271
498
  config.onError?.(error2);
272
499
  } finally {
273
500
  setIsStreaming(false);
274
501
  }
275
- }, [session, handleSSEEvent, config]);
502
+ }, [session, handleSSEEvent, handleSessionDeleted, config]);
276
503
  const applyPendingValue = useCallback(async () => {
277
504
  if (!clientRef.current || !session) return;
278
505
  try {
@@ -282,11 +509,15 @@ function InformedAIProvider({ config, children }) {
282
509
  config.onSessionChange?.(result.session);
283
510
  config.onFieldApply?.(result.appliedField, result.appliedValue);
284
511
  } catch (err) {
512
+ if (isSessionNotFoundError(err)) {
513
+ await handleSessionDeleted();
514
+ return;
515
+ }
285
516
  const error2 = err instanceof Error ? err : new Error("Failed to apply value");
286
517
  setError(error2);
287
518
  config.onError?.(error2);
288
519
  }
289
- }, [session, config]);
520
+ }, [session, handleSessionDeleted, config]);
290
521
  const skipTask = useCallback(async () => {
291
522
  if (!clientRef.current || !session) return;
292
523
  try {
@@ -295,11 +526,55 @@ function InformedAIProvider({ config, children }) {
295
526
  setSession(newSession);
296
527
  config.onSessionChange?.(newSession);
297
528
  } catch (err) {
529
+ if (isSessionNotFoundError(err)) {
530
+ await handleSessionDeleted();
531
+ return;
532
+ }
298
533
  const error2 = err instanceof Error ? err : new Error("Failed to skip task");
299
534
  setError(error2);
300
535
  config.onError?.(error2);
301
536
  }
302
- }, [session, config]);
537
+ }, [session, handleSessionDeleted, config]);
538
+ const startNewSession = useCallback(async () => {
539
+ if (!clientRef.current) return;
540
+ try {
541
+ setIsLoading(true);
542
+ setError(null);
543
+ clearPersistedSession();
544
+ const result = await createNewSession();
545
+ if (result) {
546
+ setSession(result.session);
547
+ setDocument(result.document);
548
+ setDocumentType(result.documentType);
549
+ config.onSessionChange?.(result.session);
550
+ config.onReady?.({
551
+ session: result.session,
552
+ document: result.document,
553
+ documentType: result.documentType
554
+ });
555
+ }
556
+ } catch (err) {
557
+ const error2 = err instanceof Error ? err : new Error("Failed to start new session");
558
+ setError(error2);
559
+ config.onError?.(error2);
560
+ } finally {
561
+ setIsLoading(false);
562
+ }
563
+ }, [createNewSession, clearPersistedSession, config]);
564
+ const endSession = useCallback(async () => {
565
+ if (!clientRef.current || !session) return;
566
+ try {
567
+ setError(null);
568
+ stopHeartbeat();
569
+ await clientRef.current.endSession(session.id);
570
+ clearPersistedSession();
571
+ setSession((prev) => prev ? { ...prev, status: "ended" } : null);
572
+ } catch (err) {
573
+ const error2 = err instanceof Error ? err : new Error("Failed to end session");
574
+ setError(error2);
575
+ config.onError?.(error2);
576
+ }
577
+ }, [session, clearPersistedSession, stopHeartbeat, config]);
303
578
  const clearError = useCallback(() => {
304
579
  setError(null);
305
580
  }, []);
@@ -315,6 +590,8 @@ function InformedAIProvider({ config, children }) {
315
590
  sendQuickAction,
316
591
  applyPendingValue,
317
592
  skipTask,
593
+ startNewSession,
594
+ endSession,
318
595
  clearError
319
596
  };
320
597
  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.2.6",
3
+ "version": "0.4.0",
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",