@constructor-io/constructorio-ui-asa 1.7.2 → 1.8.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.
Files changed (73) hide show
  1. package/README.md +21 -17
  2. package/dist/constructorio-ui-asa-bundled.js +17 -17
  3. package/lib/cjs/components/Chat/AiMessage.js +2 -3
  4. package/lib/cjs/components/Chat/Chat.js +38 -10
  5. package/lib/cjs/components/CioAsa/CioAsa.js +6 -2
  6. package/lib/cjs/components/CioAsaProvider/CioAsaProvider.js +44 -3
  7. package/lib/cjs/components/ResultsBlock/ResultsGroup.js +5 -5
  8. package/lib/cjs/hooks/agentStream.js +113 -0
  9. package/lib/cjs/hooks/asaStreamHandlers.js +2 -1
  10. package/lib/cjs/hooks/persistence/backgroundTurns.js +121 -0
  11. package/lib/cjs/hooks/persistence/useChatPersistence.js +485 -0
  12. package/lib/cjs/hooks/persistence/useForeignStream.js +34 -0
  13. package/lib/cjs/hooks/persistence/useThreadList.js +45 -0
  14. package/lib/cjs/hooks/persistence/useWriteQueue.js +17 -0
  15. package/lib/cjs/hooks/useAsaResults.js +93 -160
  16. package/lib/cjs/hooks/useAsaTracking.js +3 -3
  17. package/lib/cjs/hooks/useIsMounted.js +15 -0
  18. package/lib/cjs/hooks/useLatest.js +10 -0
  19. package/lib/cjs/hooks/useViewportTracking.js +4 -3
  20. package/lib/cjs/index.js +3 -1
  21. package/lib/cjs/utils/chatRecordValidation.js +54 -0
  22. package/lib/cjs/utils/chatSession.js +98 -0
  23. package/lib/cjs/utils/chatThreads.js +248 -0
  24. package/lib/cjs/utils/clientOptions.js +11 -0
  25. package/lib/cjs/utils/localStoragePersistence.js +391 -0
  26. package/lib/cjs/utils/translate.js +1 -0
  27. package/lib/cjs/version.js +1 -1
  28. package/lib/mjs/components/Chat/AiMessage.js +2 -3
  29. package/lib/mjs/components/Chat/Chat.js +36 -10
  30. package/lib/mjs/components/CioAsa/CioAsa.js +6 -2
  31. package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +44 -4
  32. package/lib/mjs/components/ResultsBlock/ResultsGroup.js +2 -1
  33. package/lib/mjs/hooks/agentStream.js +106 -0
  34. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  35. package/lib/mjs/hooks/persistence/backgroundTurns.js +120 -0
  36. package/lib/mjs/hooks/persistence/useChatPersistence.js +474 -0
  37. package/lib/mjs/hooks/persistence/useForeignStream.js +31 -0
  38. package/lib/mjs/hooks/persistence/useThreadList.js +41 -0
  39. package/lib/mjs/hooks/persistence/useWriteQueue.js +14 -0
  40. package/lib/mjs/hooks/useAsaResults.js +87 -157
  41. package/lib/mjs/hooks/useAsaTracking.js +2 -1
  42. package/lib/mjs/hooks/useIsMounted.js +12 -0
  43. package/lib/mjs/hooks/useLatest.js +7 -0
  44. package/lib/mjs/hooks/useViewportTracking.js +3 -3
  45. package/lib/mjs/index.js +1 -0
  46. package/lib/mjs/utils/chatRecordValidation.js +51 -0
  47. package/lib/mjs/utils/chatSession.js +86 -0
  48. package/lib/mjs/utils/chatThreads.js +219 -0
  49. package/lib/mjs/utils/clientOptions.js +7 -0
  50. package/lib/mjs/utils/localStoragePersistence.js +373 -0
  51. package/lib/mjs/utils/translate.js +1 -0
  52. package/lib/mjs/version.js +1 -1
  53. package/lib/types/components/Chat/Chat.d.ts +8 -2
  54. package/lib/types/components/CioAsa/CioAsa.d.ts +1 -1
  55. package/lib/types/hooks/agentStream.d.ts +48 -0
  56. package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
  57. package/lib/types/hooks/persistence/backgroundTurns.d.ts +43 -0
  58. package/lib/types/hooks/persistence/useChatPersistence.d.ts +34 -0
  59. package/lib/types/hooks/persistence/useForeignStream.d.ts +9 -0
  60. package/lib/types/hooks/persistence/useThreadList.d.ts +9 -0
  61. package/lib/types/hooks/persistence/useWriteQueue.d.ts +5 -0
  62. package/lib/types/hooks/useAsaTracking.d.ts +2 -0
  63. package/lib/types/hooks/useIsMounted.d.ts +3 -0
  64. package/lib/types/hooks/useLatest.d.ts +3 -0
  65. package/lib/types/index.d.ts +2 -1
  66. package/lib/types/types.d.ts +134 -2
  67. package/lib/types/utils/chatRecordValidation.d.ts +2 -0
  68. package/lib/types/utils/chatSession.d.ts +45 -0
  69. package/lib/types/utils/chatThreads.d.ts +63 -0
  70. package/lib/types/utils/clientOptions.d.ts +5 -0
  71. package/lib/types/utils/localStoragePersistence.d.ts +26 -0
  72. package/lib/types/version.d.ts +1 -1
  73. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  import React, { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
2
2
  import useAsaResults from '../../hooks/useAsaResults';
3
+ import { useCioAsaContext } from '../../hooks/useCioAsaContext';
3
4
  import useFocusTrap from '../../hooks/useFocusTrap';
5
+ import useLatest from '../../hooks/useLatest';
6
+ import { normalizeItemToProduct } from '../../utils/productNormalizer';
4
7
  import translate from '../../utils/translate';
5
8
  import ChatHeader from './ChatHeader';
6
9
  import WelcomeScreen from './WelcomeScreen';
@@ -22,20 +25,41 @@ function getAnnouncement(messages, translations) {
22
25
  }
23
26
  return '';
24
27
  }
25
- const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, showStopButton = false, }, ref) => {
26
- const { messages, sendMessage, isStreaming, abort, clearHistory } = useAsaResults({
28
+ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, onThreadsChange, showStopButton = false, }, ref) => {
29
+ const { messages, sendMessage, isStreaming, canAbort, abort, clearHistory, isHydrating, threads, activeThreadId, newThread, switchThread, } = useAsaResults({
27
30
  initialThreadId,
31
+ normalizeItem: (item) => (normalizeItem ?? normalizeItemToProduct)(item, {
32
+ saleBadgeText: translate('CioAsa.results.saleBadge', translations),
33
+ }),
28
34
  });
29
35
  const chatViewRef = useRef(null);
30
36
  const containerRef = useRef(null);
31
37
  const isWelcome = messages.length === 0;
38
+ // While another thread loads, keep the chat frame (header, close button) instead of a blank body.
39
+ const lastViewRef = useRef(null);
40
+ if (!isHydrating)
41
+ lastViewRef.current = isWelcome ? 'welcome' : 'chat';
42
+ const showWelcome = !isHydrating && isWelcome;
43
+ const showChat = !isHydrating && !isWelcome;
44
+ const showChatFrame = showChat || (isHydrating && lastViewRef.current === 'chat');
32
45
  const isModal = typeof onClose === 'function';
33
46
  const announcement = getAnnouncement(messages, translations);
34
- useImperativeHandle(ref, () => ({
47
+ useImperativeHandle(ref, () => ({ abort, clearHistory, newThread, switchThread }), [
35
48
  abort,
36
49
  clearHistory,
37
- }));
50
+ newThread,
51
+ switchThread,
52
+ ]);
53
+ const hasPersistence = Boolean(useCioAsaContext()?.persistence);
54
+ const onThreadsChangeRef = useLatest(onThreadsChange);
38
55
  useEffect(() => {
56
+ if (!hasPersistence || isHydrating)
57
+ return;
58
+ onThreadsChangeRef.current?.(threads, activeThreadId);
59
+ }, [threads, activeThreadId, isHydrating, hasPersistence, onThreadsChangeRef]);
60
+ useEffect(() => {
61
+ if (isHydrating)
62
+ return;
39
63
  const root = isWelcome ? containerRef.current : chatViewRef.current;
40
64
  const input = root?.querySelector('input');
41
65
  if (input) {
@@ -48,15 +72,17 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
48
72
  input.focus();
49
73
  }
50
74
  }
51
- }, [isWelcome]);
75
+ }, [isWelcome, isHydrating]);
52
76
  useFocusTrap(containerRef, { onEscape: onClose, trapFocus: isModal });
53
77
  return (React.createElement("div", { className: ['cio-asa', 'cio-asa-chat', className].filter(Boolean).join(' '), ref: containerRef, role: 'dialog', "aria-modal": isModal ? 'true' : undefined, "aria-label": translate(isWelcome ? 'CioAsa.welcome.title' : 'CioAsa.header.title', translations) },
54
78
  React.createElement("div", { className: 'cio-sr-only', role: 'status' }, announcement),
55
- React.createElement("div", { className: 'cio-asa-chat-body' }, isWelcome ? (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--welcome' },
56
- React.createElement(WelcomeScreen, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides?.welcomeScreen }))) : (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
57
- React.createElement(ChatHeader, { onClose: onClose, translations: translations, componentOverrides: componentOverrides?.header }),
58
- React.createElement(ChatMessageList, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: translate('CioAsa.results.addToCart', translations), viewMoreText: translate('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides?.aiMessage, userMessageOverrides: componentOverrides?.userMessage, resultsBlockOverrides: componentOverrides?.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
59
- React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, isStreaming: isStreaming, onAbort: abort, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides?.input }))))));
79
+ React.createElement("div", { className: 'cio-asa-chat-body' },
80
+ showWelcome && (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--welcome' },
81
+ React.createElement(WelcomeScreen, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides?.welcomeScreen }))),
82
+ showChatFrame && (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
83
+ React.createElement(ChatHeader, { onClose: onClose, translations: translations, componentOverrides: componentOverrides?.header }),
84
+ React.createElement(ChatMessageList, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: translate('CioAsa.results.addToCart', translations), viewMoreText: translate('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides?.aiMessage, userMessageOverrides: componentOverrides?.userMessage, resultsBlockOverrides: componentOverrides?.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
85
+ React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming || isHydrating, isStreaming: isStreaming, onAbort: canAbort ? abort : undefined, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides?.input }))))));
60
86
  });
61
87
  Chat.displayName = 'Chat';
62
88
  export default Chat;
@@ -12,8 +12,12 @@ import Chat from '../Chat/Chat';
12
12
  const CioAsa = React.forwardRef((props, ref) => {
13
13
  const {
14
14
  // Provider configuration — everything else belongs to Chat.
15
- apiKey, cioClient, testCells, staticRequestConfigs, formatters, urlHelpers, callbacks, section, ...chatProps } = props;
16
- return (React.createElement(CioAsaProvider, { apiKey: apiKey, cioClient: cioClient, testCells: testCells, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section },
15
+ apiKey, cioClient, testCells, staticRequestConfigs, formatters, urlHelpers, callbacks, section, persistConversation, userId = null, ...chatProps } = props;
16
+ // Split so TypeScript can match the pair against the provider's props.
17
+ const persistenceProps = persistConversation
18
+ ? { persistConversation, userId }
19
+ : { userId };
20
+ return (React.createElement(CioAsaProvider, { apiKey: apiKey, cioClient: cioClient, testCells: testCells, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section, ...persistenceProps },
17
21
  React.createElement(Chat, { ...chatProps, ref: ref })));
18
22
  });
19
23
  CioAsa.displayName = 'CioAsa';
@@ -1,17 +1,43 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import useCioClient from '../../hooks/useCioClient';
3
3
  import { AsaContext } from '../../hooks/useCioAsaContext';
4
4
  import * as defaultFormatters from '../../utils/formatters';
5
5
  import * as defaultUrlHelpers from '../../utils/urlHelpers';
6
+ import { readClientOptions } from '../../utils/clientOptions';
7
+ import { persistenceNamespace, sharedLocalStoragePersistence, shopperId, storageAreaFor, } from '../../utils/localStoragePersistence';
6
8
  export default function CioAsaProvider(props) {
7
- const { apiKey, formatters, urlHelpers, staticRequestConfigs = { domain: 'chatbot' }, cioClient: customCioClient, testCells, callbacks, section = 'Products', children, } = props;
9
+ const { apiKey, formatters, urlHelpers, staticRequestConfigs = { domain: 'chatbot' }, cioClient: customCioClient, testCells, callbacks, section = 'Products', persistConversation: persistOptions, userId: userIdProp, children, } = props;
10
+ // Read field by field: an inline options object is a new reference on every render.
11
+ const persistenceEnabled = Boolean(persistOptions?.enabled);
8
12
  const [cioClientOptions, setCioClientOptions] = useState({});
13
+ const initialUserId = useRef(userIdProp).current;
14
+ const clientInit = useMemo(() => initialUserId === undefined
15
+ ? cioClientOptions
16
+ : { ...cioClientOptions, userId: shopperId(initialUserId) }, [cioClientOptions, initialUserId]);
9
17
  const cioClient = useCioClient({
10
18
  apiKey,
11
19
  cioClient: customCioClient,
12
- cioClientOptions,
20
+ cioClientOptions: clientInit,
13
21
  testCells,
14
22
  });
23
+ const userId = shopperId(userIdProp);
24
+ const syncUserId = persistenceEnabled || userIdProp !== undefined;
25
+ useEffect(() => {
26
+ if (customCioClient || !cioClient || !syncUserId)
27
+ return;
28
+ cioClient.setClientOptions({ userId });
29
+ }, [cioClient, customCioClient, syncUserId, userId]);
30
+ const resolvedApiKey = apiKey ?? readClientOptions(cioClient)?.apiKey;
31
+ const { domain } = staticRequestConfigs;
32
+ const persistence = useMemo(() => {
33
+ if (!persistenceEnabled || resolvedApiKey === undefined)
34
+ return undefined;
35
+ return sharedLocalStoragePersistence(persistenceNamespace({ apiKey: resolvedApiKey, domain, userId }), storageAreaFor(userId));
36
+ }, [persistenceEnabled, resolvedApiKey, domain, userId]);
37
+ const persistenceScope = persistence && (userId === undefined ? 'guest' : 'user');
38
+ const persistenceIndex = persistence && resolvedApiKey !== undefined
39
+ ? persistenceNamespace({ apiKey: resolvedApiKey, domain })
40
+ : undefined;
15
41
  const contextValue = useMemo(() => ({
16
42
  cioClient,
17
43
  cioClientOptions,
@@ -21,6 +47,20 @@ export default function CioAsaProvider(props) {
21
47
  urlHelpers: { ...defaultUrlHelpers, ...urlHelpers },
22
48
  callbacks,
23
49
  section,
24
- }), [cioClient, cioClientOptions, formatters, urlHelpers, staticRequestConfigs, callbacks, section]);
50
+ persistence,
51
+ persistenceScope,
52
+ persistenceIndex,
53
+ }), [
54
+ cioClient,
55
+ cioClientOptions,
56
+ formatters,
57
+ urlHelpers,
58
+ staticRequestConfigs,
59
+ callbacks,
60
+ section,
61
+ persistence,
62
+ persistenceScope,
63
+ persistenceIndex,
64
+ ]);
25
65
  return (React.createElement(AsaContext.Provider, { value: contextValue }, typeof children === 'function' ? children(contextValue) : children));
26
66
  }
@@ -18,7 +18,8 @@ function NextButton() {
18
18
  */
19
19
  export default function ResultsGroup({ groupData, label, slidesToShow, gap, showTitle, viewMoreText, addToCartText, saleBadgeText, currency, intent, intentResultId, normalizeItem, onProductClick, onAddToCart, onViewMore, componentOverrides, tracking, callbacks, }) {
20
20
  const { searchResultId } = groupData;
21
- const products = groupData.searchResults.map((item) => normalizeItem(item, { saleBadgeText }));
21
+ const products = groupData.products ??
22
+ groupData.searchResults.map((item) => normalizeItem(item, { saleBadgeText }));
22
23
  const canTrack = !!intent && !!searchResultId;
23
24
  const { ref: viewRef } = useViewportTracking({
24
25
  enabled: canTrack,
@@ -0,0 +1,106 @@
1
+ import { handleFollowUpRefinement, handleMessage, handleSearchResult, handleServerError, handleStreamEnd, handleStreamError, updateMessageById, } from './asaStreamHandlers';
2
+ /** Drains an agent stream into the assistant message identified by `assistantId`. */
3
+ export function readAgentStream(stream, assistantId, setMessages, callbacks) {
4
+ const reader = stream.getReader();
5
+ let cancelled = false;
6
+ let pendingGroup = null;
7
+ let intentResultId;
8
+ let loadStartFired = false;
9
+ let groupCount = 0;
10
+ const patchAssistant = (patch) => {
11
+ updateMessageById(setMessages, assistantId, (m) => ({ ...m, ...patch }));
12
+ };
13
+ const fireLoadStart = () => {
14
+ if (loadStartFired)
15
+ return;
16
+ loadStartFired = true;
17
+ callbacks.onLoadStart(intentResultId);
18
+ };
19
+ const done = (async () => {
20
+ try {
21
+ while (!cancelled) {
22
+ // eslint-disable-next-line no-await-in-loop
23
+ const res = await reader.read();
24
+ if (cancelled)
25
+ break;
26
+ if (res.done) {
27
+ handleStreamEnd(assistantId, setMessages);
28
+ callbacks.onFinish({ searchResultCount: groupCount, intentResultId });
29
+ break;
30
+ }
31
+ const { type, data } = res.value;
32
+ if (!intentResultId && data?.intent_result_id) {
33
+ intentResultId = data.intent_result_id;
34
+ patchAssistant({ intentResultId });
35
+ }
36
+ if (type === 'start') {
37
+ if (data?.thread_id)
38
+ patchAssistant({ threadId: data.thread_id });
39
+ callbacks.onStart(data?.thread_id);
40
+ fireLoadStart();
41
+ }
42
+ else if (type === 'group') {
43
+ pendingGroup = {
44
+ display_name: data?.display_name ?? data?.group ?? '',
45
+ value: data?.value ?? data?.group ?? '',
46
+ };
47
+ }
48
+ else if (type === 'search_result') {
49
+ fireLoadStart();
50
+ groupCount += 1;
51
+ pendingGroup = handleSearchResult(data, pendingGroup, assistantId, setMessages);
52
+ }
53
+ else if (type === 'message') {
54
+ fireLoadStart();
55
+ handleMessage(data, assistantId, setMessages);
56
+ }
57
+ else if (type === 'follow_up_refinement') {
58
+ fireLoadStart();
59
+ handleFollowUpRefinement(data, assistantId, setMessages);
60
+ }
61
+ else if (type === 'server_error') {
62
+ handleServerError(assistantId, setMessages);
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ if (!cancelled)
69
+ handleStreamError(assistantId, setMessages);
70
+ }
71
+ finally {
72
+ // Cancelling an errored stream rejects with the stored error; nothing here needs it.
73
+ if (!cancelled)
74
+ reader.cancel().catch(() => { });
75
+ }
76
+ })();
77
+ return {
78
+ assistantId,
79
+ done,
80
+ cancel: () => {
81
+ cancelled = true;
82
+ reader.cancel().catch(() => { });
83
+ },
84
+ };
85
+ }
86
+ /** Streams into `assistant`, reporting to the owner `createOwner` returns for the new turn. */
87
+ export function startTurn(stream, assistant, createOwner, callbacks) {
88
+ const turn = { assistant };
89
+ turn.owner = createOwner(turn);
90
+ const handle = readAgentStream(stream, assistant.id, (action) => {
91
+ if (typeof action === 'function')
92
+ [turn.assistant] = action([turn.assistant]);
93
+ turn.owner.setMessages(action);
94
+ }, {
95
+ onLoadStart: callbacks.onLoadStart,
96
+ onFinish: (result) => callbacks.onFinish({ ...result, threadId: turn.serverThreadId }),
97
+ onStart: (threadId) => {
98
+ if (threadId)
99
+ turn.serverThreadId = threadId;
100
+ turn.owner.onStart(threadId);
101
+ },
102
+ });
103
+ Object.assign(turn, handle);
104
+ handle.done.then(() => turn.owner.onDone());
105
+ return turn;
106
+ }
@@ -8,7 +8,7 @@ const INTERNAL_REQUEST_FIELDS = ['features', 'feature_variants'];
8
8
  function omitInternalRequestFields(request) {
9
9
  return Object.fromEntries(Object.entries(request).filter(([key]) => !INTERNAL_REQUEST_FIELDS.includes(key)));
10
10
  }
11
- function updateMessageById(setMessages, id, patch) {
11
+ export function updateMessageById(setMessages, id, patch) {
12
12
  setMessages((prev) => prev.map((msg) => (msg.id === id ? patch(msg) : msg)));
13
13
  }
14
14
  export function handleSearchResult(data, pendingGroup, assistantId, setMessages) {
@@ -0,0 +1,120 @@
1
+ import { prepareSnapshot } from '../../utils/chatSession';
2
+ import { normalizeHydratedMessages, saveThreadAndRetireStale } from '../../utils/chatThreads';
3
+ const turns = new Set();
4
+ const listeners = new Set();
5
+ const notify = () => listeners.forEach((listener) => listener());
6
+ /** An answer that went on after its conversation left the screen, until it is stored or shown again. */
7
+ export class BackgroundTurn {
8
+ turn;
9
+ store;
10
+ /** The conversation's own session, forked from the chat that sent the message. */
11
+ session;
12
+ messages;
13
+ streaming = true;
14
+ /** Whether the latest snapshot is known to be stored. */
15
+ saved = false;
16
+ /** Every id the conversation was stored under, so a list read before a re-key still finds it. */
17
+ ids = new Set();
18
+ /** Its writes, in order; a chat that takes the turn back writes after them. */
19
+ writes;
20
+ constructor(turn, params) {
21
+ this.turn = turn;
22
+ this.store = params.store;
23
+ this.session = params.session;
24
+ this.messages = params.messages;
25
+ this.writes = params.after;
26
+ if (this.session.storageThreadId)
27
+ this.ids.add(this.session.storageThreadId);
28
+ this.turn.owner = {
29
+ setMessages: (action) => {
30
+ this.messages = typeof action === 'function' ? action(this.messages) : action;
31
+ },
32
+ onStart: (threadId) => {
33
+ if (threadId)
34
+ this.session.serverThreadId = threadId;
35
+ this.save().then(notify);
36
+ },
37
+ onDone: () => {
38
+ this.streaming = false;
39
+ this.messages = normalizeHydratedMessages(this.messages);
40
+ this.save().then(() => {
41
+ if (this.saved)
42
+ turns.delete(this);
43
+ notify();
44
+ });
45
+ },
46
+ };
47
+ }
48
+ save() {
49
+ const { snapshot, staleIds } = prepareSnapshot(this.session, this.messages);
50
+ const { store } = this;
51
+ this.ids.add(snapshot.threadId);
52
+ this.saved = false;
53
+ this.writes = this.writes
54
+ .then(() => saveThreadAndRetireStale(store, snapshot, staleIds))
55
+ .then(({ saved, leftover }) => {
56
+ if (leftover.length > 0) {
57
+ this.session.orphanIds = Array.from(new Set([...this.session.orphanIds, ...leftover]));
58
+ }
59
+ if (this.session.lastSyncedAt === snapshot.updatedAt)
60
+ this.saved = saved;
61
+ })
62
+ .catch(() => { });
63
+ return this.writes;
64
+ }
65
+ /** Later writes go into `store`. */
66
+ moveTo(store) {
67
+ this.store = store;
68
+ }
69
+ /** Stores the conversation settled, without awaiting: the page is going away. */
70
+ flush() {
71
+ if (!this.streaming && this.saved)
72
+ return;
73
+ const { snapshot, staleIds } = prepareSnapshot(this.session, normalizeHydratedMessages(this.messages));
74
+ if (this.store.saveThreadSync)
75
+ this.store.saveThreadSync(snapshot, staleIds);
76
+ else
77
+ this.store.saveThread(snapshot).catch(() => { });
78
+ }
79
+ }
80
+ let listeningForPagehide = false;
81
+ /**
82
+ * Keeps `turn` streaming with no chat showing it. It is saved into `store` at once, on `start`
83
+ * and when done, each write queued behind `after`, and is dropped from here once the last one landed.
84
+ */
85
+ export function continueInBackground(turn, params) {
86
+ const bg = new BackgroundTurn(turn, params);
87
+ turns.add(bg);
88
+ if (!listeningForPagehide && typeof window !== 'undefined') {
89
+ listeningForPagehide = true;
90
+ window.addEventListener('pagehide', () => turns.forEach((t) => t.flush()));
91
+ }
92
+ bg.save().then(notify);
93
+ }
94
+ /**
95
+ * Takes back the background turn stored in `store` under `threadId` for a chat to show, handing an
96
+ * unfinished one to `createOwner`; `null` if there is none.
97
+ */
98
+ export function resumeBackgroundTurn(store, threadId, createOwner) {
99
+ const bg = Array.from(turns).find((t) => t.store === store && t.ids.has(threadId));
100
+ if (!bg)
101
+ return null;
102
+ turns.delete(bg);
103
+ if (bg.streaming)
104
+ bg.turn.owner = createOwner(bg.turn);
105
+ return bg;
106
+ }
107
+ /** Points the background turns of `from` at `to`; resolves once their writes into `from` settled. */
108
+ export function moveBackgroundTurns(from, to) {
109
+ const moving = Array.from(turns).filter((bg) => bg.store === from);
110
+ const settled = Promise.all(moving.map((bg) => bg.writes)).then(() => { });
111
+ moving.forEach((bg) => bg.moveTo(to));
112
+ return settled;
113
+ }
114
+ /** Called whenever a background turn is stored or finishes. */
115
+ export function subscribeBackgroundTurns(listener) {
116
+ listeners.add(listener);
117
+ return () => {
118
+ listeners.delete(listener);
119
+ };
120
+ }