@constructor-io/constructorio-ui-asa 1.7.1 → 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 (76) hide show
  1. package/README.md +21 -17
  2. package/dist/constructorio-ui-asa-bundled.js +18 -18
  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/ResultsBlock.js +0 -1
  8. package/lib/cjs/components/ResultsBlock/ResultsGroup.js +5 -5
  9. package/lib/cjs/hooks/agentStream.js +113 -0
  10. package/lib/cjs/hooks/asaStreamHandlers.js +2 -1
  11. package/lib/cjs/hooks/persistence/backgroundTurns.js +121 -0
  12. package/lib/cjs/hooks/persistence/useChatPersistence.js +485 -0
  13. package/lib/cjs/hooks/persistence/useForeignStream.js +34 -0
  14. package/lib/cjs/hooks/persistence/useThreadList.js +45 -0
  15. package/lib/cjs/hooks/persistence/useWriteQueue.js +17 -0
  16. package/lib/cjs/hooks/useAsaResults.js +93 -160
  17. package/lib/cjs/hooks/useAsaTracking.js +3 -3
  18. package/lib/cjs/hooks/useIsMounted.js +15 -0
  19. package/lib/cjs/hooks/useLatest.js +10 -0
  20. package/lib/cjs/hooks/useViewportTracking.js +4 -3
  21. package/lib/cjs/index.js +3 -1
  22. package/lib/cjs/utils/chatRecordValidation.js +54 -0
  23. package/lib/cjs/utils/chatSession.js +98 -0
  24. package/lib/cjs/utils/chatThreads.js +248 -0
  25. package/lib/cjs/utils/clientOptions.js +11 -0
  26. package/lib/cjs/utils/localStoragePersistence.js +391 -0
  27. package/lib/cjs/utils/translate.js +1 -0
  28. package/lib/cjs/version.js +1 -1
  29. package/lib/mjs/components/Chat/AiMessage.js +2 -3
  30. package/lib/mjs/components/Chat/Chat.js +36 -10
  31. package/lib/mjs/components/CioAsa/CioAsa.js +6 -2
  32. package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +44 -4
  33. package/lib/mjs/components/ResultsBlock/ResultsBlock.js +0 -1
  34. package/lib/mjs/components/ResultsBlock/ResultsGroup.js +2 -1
  35. package/lib/mjs/hooks/agentStream.js +106 -0
  36. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  37. package/lib/mjs/hooks/persistence/backgroundTurns.js +120 -0
  38. package/lib/mjs/hooks/persistence/useChatPersistence.js +474 -0
  39. package/lib/mjs/hooks/persistence/useForeignStream.js +31 -0
  40. package/lib/mjs/hooks/persistence/useThreadList.js +41 -0
  41. package/lib/mjs/hooks/persistence/useWriteQueue.js +14 -0
  42. package/lib/mjs/hooks/useAsaResults.js +87 -157
  43. package/lib/mjs/hooks/useAsaTracking.js +2 -1
  44. package/lib/mjs/hooks/useIsMounted.js +12 -0
  45. package/lib/mjs/hooks/useLatest.js +7 -0
  46. package/lib/mjs/hooks/useViewportTracking.js +3 -3
  47. package/lib/mjs/index.js +1 -0
  48. package/lib/mjs/utils/chatRecordValidation.js +51 -0
  49. package/lib/mjs/utils/chatSession.js +86 -0
  50. package/lib/mjs/utils/chatThreads.js +219 -0
  51. package/lib/mjs/utils/clientOptions.js +7 -0
  52. package/lib/mjs/utils/localStoragePersistence.js +373 -0
  53. package/lib/mjs/utils/translate.js +1 -0
  54. package/lib/mjs/version.js +1 -1
  55. package/lib/types/components/Chat/Chat.d.ts +8 -2
  56. package/lib/types/components/CioAsa/CioAsa.d.ts +1 -1
  57. package/lib/types/components/ResultsBlock/ResultsBlock.d.ts +0 -1
  58. package/lib/types/hooks/agentStream.d.ts +48 -0
  59. package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
  60. package/lib/types/hooks/persistence/backgroundTurns.d.ts +43 -0
  61. package/lib/types/hooks/persistence/useChatPersistence.d.ts +34 -0
  62. package/lib/types/hooks/persistence/useForeignStream.d.ts +9 -0
  63. package/lib/types/hooks/persistence/useThreadList.d.ts +9 -0
  64. package/lib/types/hooks/persistence/useWriteQueue.d.ts +5 -0
  65. package/lib/types/hooks/useAsaTracking.d.ts +2 -0
  66. package/lib/types/hooks/useIsMounted.d.ts +3 -0
  67. package/lib/types/hooks/useLatest.d.ts +3 -0
  68. package/lib/types/index.d.ts +2 -1
  69. package/lib/types/types.d.ts +134 -2
  70. package/lib/types/utils/chatRecordValidation.d.ts +2 -0
  71. package/lib/types/utils/chatSession.d.ts +45 -0
  72. package/lib/types/utils/chatThreads.d.ts +63 -0
  73. package/lib/types/utils/clientOptions.d.ts +5 -0
  74. package/lib/types/utils/localStoragePersistence.d.ts +26 -0
  75. package/lib/types/version.d.ts +1 -1
  76. package/package.json +1 -1
@@ -1,188 +1,113 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useCioAsaContext } from './useCioAsaContext';
3
- import { handleSearchResult, handleMessage, handleFollowUpRefinement, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
4
3
  import useAsaTracking from './useAsaTracking';
4
+ import useChatPersistence from './persistence/useChatPersistence';
5
+ import { startTurn } from './agentStream';
6
+ import { createChatSession, nextMessageId } from '../utils/chatSession';
7
+ import { normalizeItemToProduct } from '../utils/productNormalizer';
8
+ import useLatest from './useLatest';
5
9
  export default function useAsaResults(options) {
6
- const [messages, setMessages] = useState([]);
7
- const [isStreaming, setIsStreaming] = useState(false);
8
- const isStreamingRef = useRef(false);
9
- const killSwitchRef = useRef(false);
10
- const readerRef = useRef(null);
11
- const threadIdRef = useRef(options?.initialThreadId ?? null);
12
- const idCounterRef = useRef(0);
13
- // The assistant message the in-flight stream is writing into, so `abort` can
14
- // settle it without having to guess which message that is.
15
- const activeAssistantIdRef = useRef(null);
16
10
  const contextValue = useCioAsaContext();
17
11
  if (!contextValue) {
18
12
  throw new Error('useAsaResults must be used within a CioAsaProvider.');
19
13
  }
20
- const { cioClient, staticRequestConfigs, callbacks, section } = contextValue;
14
+ const { cioClient, staticRequestConfigs, callbacks, section, persistence, persistenceScope, persistenceIndex, } = contextValue;
21
15
  const { domain } = staticRequestConfigs || {};
22
16
  if (!cioClient || !domain) {
23
17
  throw new Error('useAsaResults requires a configured cioClient and domain. Check your CioAsaProvider props.');
24
18
  }
19
+ const [messages, setMessages] = useState([]);
20
+ const [isStreaming, setIsStreaming] = useState(false);
21
+ const normalizeItemRef = useLatest(options?.normalizeItem);
22
+ const sessionRef = useRef(null);
23
+ if (!sessionRef.current) {
24
+ sessionRef.current = createChatSession(options?.initialThreadId, (item) => (normalizeItemRef.current ?? normalizeItemToProduct)(item));
25
+ }
26
+ const session = sessionRef.current;
27
+ const turnRef = useRef(null);
25
28
  const tracking = useAsaTracking({
26
29
  tracker: cioClient.tracker,
27
30
  section,
28
- threadId: threadIdRef.current ?? undefined,
31
+ threadId: session.serverThreadId ?? undefined,
29
32
  });
30
- const trackingRef = useRef(tracking);
31
- trackingRef.current = tracking;
32
- const callbacksRef = useRef(callbacks);
33
- callbacksRef.current = callbacks;
34
- const staticRequestConfigsRef = useRef(staticRequestConfigs);
35
- staticRequestConfigsRef.current = staticRequestConfigs;
36
- /**
37
- * Stops the in-flight stream. The kill switch is raised *before* cancelling because
38
- * cancelling makes the pending `reader.read()` resolve `{ done: true }` — identical
39
- * to a stream that ended on its own. Without the flag already set, the read loop
40
- * would take the end-of-stream branch and report a load that never finished.
41
- *
42
- * Deliberately touches no state, so the unmount cleanup can share it.
43
- */
44
- const teardownStream = useCallback(() => {
45
- killSwitchRef.current = true;
46
- if (readerRef.current) {
47
- readerRef.current.cancel();
48
- readerRef.current = null;
49
- }
50
- }, []);
51
- const nextMessageId = useCallback(() => {
52
- idCounterRef.current += 1;
53
- return `msg-${idCounterRef.current}-${Date.now()}`;
33
+ const trackingRef = useLatest(tracking);
34
+ const callbacksRef = useLatest(callbacks);
35
+ const staticRequestConfigsRef = useLatest(staticRequestConfigs);
36
+ /** Stops this tab's stream, if any. `readAgentStream` settles its `done` without touching state. */
37
+ const cancelStream = useCallback(() => {
38
+ turnRef.current?.cancel();
39
+ turnRef.current = null;
54
40
  }, []);
41
+ const store = useChatPersistence({
42
+ store: persistence,
43
+ scope: persistenceScope,
44
+ index: persistenceIndex,
45
+ session,
46
+ initialThreadId: options?.initialThreadId,
47
+ messages,
48
+ setMessages,
49
+ isStreaming,
50
+ setIsStreaming,
51
+ turnRef,
52
+ cancelStream,
53
+ });
54
+ const { beginTurn, screenOwner } = store;
55
+ useEffect(() => cancelStream, [cancelStream]);
55
56
  const sendMessage = useCallback((text, source = 'input') => {
56
57
  const intent = text.trim();
57
- if (!intent || isStreamingRef.current)
58
+ if (!intent || session.isStreaming || session.foreignInFlight)
58
59
  return;
60
+ beginTurn();
59
61
  trackingRef.current.trackSubmit(intent);
60
62
  callbacksRef.current?.onAssistantSubmit?.({ intent, source });
61
63
  const userMessage = {
62
- id: nextMessageId(),
64
+ id: nextMessageId(session),
63
65
  role: 'user',
64
66
  text: intent,
65
67
  status: 'done',
66
68
  };
67
69
  const assistantMessage = {
68
- id: nextMessageId(),
70
+ id: nextMessageId(session),
69
71
  role: 'assistant',
70
72
  text: '',
71
73
  groups: [],
72
74
  status: 'loading',
73
75
  intent,
74
76
  };
75
- activeAssistantIdRef.current = assistantMessage.id;
76
77
  setMessages((prev) => [...prev, userMessage, assistantMessage]);
77
78
  setIsStreaming(true);
78
- isStreamingRef.current = true;
79
- killSwitchRef.current = false;
80
- // `intent` is passed as the first argument and `threadId` is managed per-stream
81
- // below, so both are stripped here. Everything else configured on the provider
82
- // (filters, guard, numResultsPerEvent, numResultEvents, qsParam,
83
- // preFilterExpression, fmtOptions, ...) is forwarded to the agent as-is.
79
+ session.isStreaming = true;
84
80
  const agentParams = { ...staticRequestConfigsRef.current };
85
81
  delete agentParams.intent;
86
82
  delete agentParams.threadId;
87
83
  const stream = cioClient.agent.getAgentResultsStream(intent, {
88
84
  ...agentParams,
89
85
  domain,
90
- ...(threadIdRef.current && { threadId: threadIdRef.current }),
86
+ ...(session.serverThreadId && { threadId: session.serverThreadId }),
91
87
  });
92
- const reader = stream.getReader();
93
- readerRef.current = reader;
94
- // Mutable per-stream state kept on an object so the closures below don't capture
95
- // reassigned loop-locals (which eslint's no-loop-func forbids).
96
- const streamState = {
97
- pendingGroup: null,
98
- intentResultId: undefined,
99
- loadStartFired: false,
100
- groupCount: 0,
101
- };
102
- const patchAssistant = (patch) => {
103
- setMessages((prev) => prev.map((m) => (m.id === assistantMessage.id ? { ...m, ...patch } : m)));
104
- };
105
- const fireLoadStart = () => {
106
- if (streamState.loadStartFired)
107
- return;
108
- streamState.loadStartFired = true;
109
- const args = { intent, intentResultId: streamState.intentResultId };
110
- trackingRef.current.trackResultLoadStarted(args);
111
- callbacksRef.current?.onResultLoadStart?.(args);
112
- };
113
- (async () => {
114
- try {
115
- // eslint-disable-next-line no-await-in-loop
116
- while (!killSwitchRef.current) {
117
- // eslint-disable-next-line no-await-in-loop
118
- const res = await reader.read();
119
- if (killSwitchRef.current)
120
- break;
121
- if (res.done) {
122
- handleStreamEnd(assistantMessage.id, setMessages);
123
- const finishArgs = {
124
- intent,
125
- searchResultCount: streamState.groupCount,
126
- intentResultId: streamState.intentResultId,
127
- };
128
- trackingRef.current.trackResultLoadFinished(finishArgs);
129
- callbacksRef.current?.onResultLoadFinish?.(finishArgs);
130
- break;
131
- }
132
- const { type, data } = res.value;
133
- // `intent_result_id` is shared across the stream; capture it from the first
134
- // event that carries it so load-start/finish can attribute correctly.
135
- if (!streamState.intentResultId && data?.intent_result_id) {
136
- streamState.intentResultId = data.intent_result_id;
137
- patchAssistant({ intentResultId: streamState.intentResultId });
138
- }
139
- if (type === 'start') {
140
- if (data?.thread_id) {
141
- threadIdRef.current = data.thread_id;
142
- patchAssistant({ threadId: data.thread_id });
143
- }
144
- fireLoadStart();
145
- }
146
- else if (type === 'group') {
147
- streamState.pendingGroup = {
148
- display_name: data?.display_name ?? data?.group ?? '',
149
- value: data?.value ?? data?.group ?? '',
150
- };
151
- }
152
- else if (type === 'search_result') {
153
- fireLoadStart();
154
- streamState.groupCount += 1;
155
- streamState.pendingGroup = handleSearchResult(data, streamState.pendingGroup, assistantMessage.id, setMessages);
156
- }
157
- else if (type === 'message') {
158
- fireLoadStart();
159
- handleMessage(data, assistantMessage.id, setMessages);
160
- }
161
- else if (type === 'follow_up_refinement') {
162
- fireLoadStart();
163
- handleFollowUpRefinement(data, assistantMessage.id, setMessages);
164
- }
165
- else if (type === 'server_error') {
166
- handleServerError(assistantMessage.id, setMessages);
167
- break;
168
- }
169
- }
170
- }
171
- catch {
172
- handleStreamError(assistantMessage.id, setMessages);
173
- }
174
- finally {
175
- if (readerRef.current === reader) {
176
- reader.cancel();
177
- readerRef.current = null;
178
- activeAssistantIdRef.current = null;
179
- setIsStreaming(false);
180
- isStreamingRef.current = false;
181
- }
182
- }
183
- })();
184
- }, [cioClient, domain, nextMessageId]);
185
- useEffect(() => () => teardownStream(), [teardownStream]);
88
+ turnRef.current = startTurn(stream, assistantMessage, screenOwner, {
89
+ onLoadStart: (intentResultId) => {
90
+ const args = { intent, intentResultId };
91
+ trackingRef.current.trackResultLoadStarted(args);
92
+ callbacksRef.current?.onResultLoadStart?.(args);
93
+ },
94
+ onFinish: ({ threadId, ...result }) => {
95
+ const args = { intent, ...result };
96
+ // The turn may have finished in the background, after the chat moved to another thread.
97
+ trackingRef.current.trackResultLoadFinished({ ...args, threadId });
98
+ callbacksRef.current?.onResultLoadFinish?.(args);
99
+ },
100
+ });
101
+ }, [
102
+ cioClient,
103
+ domain,
104
+ session,
105
+ beginTurn,
106
+ screenOwner,
107
+ trackingRef,
108
+ callbacksRef,
109
+ staticRequestConfigsRef,
110
+ ]);
186
111
  /**
187
112
  * Cancels the in-flight request while keeping the conversation: the partial reply is
188
113
  * settled as `done` (so the typing indicator stops and streamed text survives) and the
@@ -199,13 +124,12 @@ export default function useAsaResults(options) {
199
124
  * `assistant_result_load_started` with no matching finished event.
200
125
  */
201
126
  const abort = useCallback(() => {
202
- if (!isStreamingRef.current)
127
+ if (!session.isStreaming)
203
128
  return;
204
- teardownStream();
129
+ const abortedId = turnRef.current?.assistantId;
130
+ cancelStream();
131
+ session.isStreaming = false;
205
132
  setIsStreaming(false);
206
- isStreamingRef.current = false;
207
- const abortedId = activeAssistantIdRef.current;
208
- activeAssistantIdRef.current = null;
209
133
  if (abortedId) {
210
134
  setMessages((prev) => prev.flatMap((msg) => {
211
135
  if (msg.id !== abortedId)
@@ -215,14 +139,20 @@ export default function useAsaResults(options) {
215
139
  return [{ ...msg, status: 'done' }];
216
140
  }));
217
141
  }
218
- }, [teardownStream]);
219
- const clearHistory = useCallback(() => {
220
- teardownStream();
221
- threadIdRef.current = null;
222
- activeAssistantIdRef.current = null;
223
- setMessages([]);
224
- setIsStreaming(false);
225
- isStreamingRef.current = false;
226
- }, [teardownStream]);
227
- return { messages, sendMessage, isStreaming, abort, clearHistory };
142
+ // With persistence on, the cancelled turn is stored as it stands.
143
+ session.dirty = true;
144
+ }, [session, cancelStream]);
145
+ return {
146
+ messages,
147
+ sendMessage,
148
+ isStreaming: isStreaming || store.foreignInFlight,
149
+ canAbort: isStreaming,
150
+ abort,
151
+ clearHistory: store.clearHistory,
152
+ isHydrating: store.isHydrating,
153
+ threads: store.threads,
154
+ activeThreadId: store.activeThreadId,
155
+ newThread: store.newThread,
156
+ switchThread: store.switchThread,
157
+ };
228
158
  }
@@ -28,12 +28,13 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
28
28
  ...base,
29
29
  });
30
30
  }, [assistant, base]);
31
- const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId }) => {
31
+ const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId, threadId: turnThreadId, }) => {
32
32
  assistant?.trackAssistantResultLoadFinished({
33
33
  intent,
34
34
  searchResultCount,
35
35
  ...(intentResultId && { intentResultId }),
36
36
  ...base,
37
+ ...(turnThreadId && { threadId: turnThreadId }),
37
38
  });
38
39
  }, [assistant, base]);
39
40
  const trackResultClick = useCallback(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
@@ -0,0 +1,12 @@
1
+ import { useEffect, useRef } from 'react';
2
+ /** A ref that is `true` while mounted, for async work that must not land after unmount. */
3
+ export default function useIsMounted() {
4
+ const ref = useRef(true);
5
+ useEffect(() => {
6
+ ref.current = true;
7
+ return () => {
8
+ ref.current = false;
9
+ };
10
+ }, []);
11
+ return ref;
12
+ }
@@ -0,0 +1,7 @@
1
+ import { useRef } from 'react';
2
+ /** A ref that always holds the latest `value`, for async code that must not read stale props. */
3
+ export default function useLatest(value) {
4
+ const ref = useRef(value);
5
+ ref.current = value;
6
+ return ref;
7
+ }
@@ -1,12 +1,12 @@
1
1
  import { useCallback, useEffect, useRef } from 'react';
2
+ import useLatest from './useLatest';
2
3
  /**
3
4
  * Fires `onView` once when the observed element scrolls into the viewport, modeling a
4
5
  * per-pod `assistant_search_result_view` impression. Unlike the PIA widget-level view
5
6
  * tracker, this fires a single impression per pod (not repeated enter/exit cycles).
6
7
  */
7
8
  export default function useViewportTracking({ onView, enabled = true, }) {
8
- const onViewRef = useRef(onView);
9
- onViewRef.current = onView;
9
+ const onViewRef = useLatest(onView);
10
10
  const firedRef = useRef(false);
11
11
  const cleanupRef = useRef(null);
12
12
  const ref = useCallback((node) => {
@@ -27,7 +27,7 @@ export default function useViewportTracking({ onView, enabled = true, }) {
27
27
  }, { threshold: 0.5 });
28
28
  observer.observe(node);
29
29
  cleanupRef.current = () => observer.disconnect();
30
- }, [enabled]);
30
+ }, [enabled, onViewRef]);
31
31
  useEffect(() => () => {
32
32
  if (cleanupRef.current)
33
33
  cleanupRef.current();
package/lib/mjs/index.js CHANGED
@@ -10,5 +10,6 @@ export { default as useAsaTracking } from './hooks/useAsaTracking';
10
10
  export { default as useCioAsaContext } from './hooks/useCioAsaContext';
11
11
  // Utils
12
12
  export { normalizeItemToProduct } from './utils/productNormalizer';
13
+ export { clearPersistedConversations } from './utils/localStoragePersistence';
13
14
  // Re-exported client value from the JS client (used to construct/type a cioClient)
14
15
  export { ConstructorIOClient } from './types';
@@ -0,0 +1,51 @@
1
+ import { PERSISTED_CHAT_VERSION } from './chatThreads';
2
+ const MESSAGE_ROLES = new Set(['user', 'assistant']);
3
+ const MESSAGE_STATUSES = new Set(['idle', 'loading', 'streaming', 'done', 'error']);
4
+ function isProduct(value) {
5
+ if (!value || typeof value !== 'object')
6
+ return false;
7
+ const p = value;
8
+ return typeof p.id === 'string' && typeof p.name === 'string';
9
+ }
10
+ function isResultGroup(value) {
11
+ if (!value || typeof value !== 'object')
12
+ return false;
13
+ const g = value;
14
+ return (Boolean(g.group) &&
15
+ typeof g.group === 'object' &&
16
+ typeof g.group.display_name === 'string' &&
17
+ Array.isArray(g.searchResults) &&
18
+ g.searchResults.every((r) => Boolean(r) && typeof r === 'object') &&
19
+ (g.products === undefined || (Array.isArray(g.products) && g.products.every(isProduct))));
20
+ }
21
+ function isRefinement(value) {
22
+ if (!value || typeof value !== 'object')
23
+ return false;
24
+ const r = value;
25
+ return (typeof r.question === 'string' &&
26
+ Array.isArray(r.options) &&
27
+ r.options.every((o) => typeof o === 'string'));
28
+ }
29
+ function isChatMessage(value) {
30
+ if (!value || typeof value !== 'object')
31
+ return false;
32
+ const m = value;
33
+ return (typeof m.id === 'string' &&
34
+ MESSAGE_ROLES.has(m.role) &&
35
+ typeof m.text === 'string' &&
36
+ MESSAGE_STATUSES.has(m.status) &&
37
+ (m.groups === undefined || (Array.isArray(m.groups) && m.groups.every(isResultGroup))) &&
38
+ (m.refinement === undefined || isRefinement(m.refinement)));
39
+ }
40
+ export default function isPersistedChat(value) {
41
+ if (!value || typeof value !== 'object')
42
+ return false;
43
+ const chat = value;
44
+ return (chat.version === PERSISTED_CHAT_VERSION &&
45
+ typeof chat.threadId === 'string' &&
46
+ Array.isArray(chat.messages) &&
47
+ chat.messages.every(isChatMessage) &&
48
+ typeof chat.createdAt === 'number' &&
49
+ typeof chat.updatedAt === 'number' &&
50
+ (chat.owner === undefined || typeof chat.owner === 'string'));
51
+ }
@@ -0,0 +1,86 @@
1
+ import { PERSISTED_CHAT_VERSION, compactMessages, createLocalThreadId, getTabId, isLocalThreadId, nextMessageCounter, randomId, } from './chatThreads';
2
+ import { normalizeItemToProduct } from './productNormalizer';
3
+ export function createChatSession(initialThreadId, normalizeItem = normalizeItemToProduct) {
4
+ return {
5
+ normalizeItem,
6
+ serverThreadId: initialThreadId && !isLocalThreadId(initialThreadId) ? initialThreadId : null,
7
+ storageThreadId: null,
8
+ orphanIds: [],
9
+ createdAt: null,
10
+ lastSyncedAt: 0,
11
+ dirty: false,
12
+ interacted: false,
13
+ loadRequest: 0,
14
+ isStreaming: false,
15
+ foreignInFlight: false,
16
+ idCounter: 0,
17
+ idSuffix: randomId().slice(0, 6),
18
+ };
19
+ }
20
+ /** Forget the current conversation. Returns the id it was stored under, if any. */
21
+ export function resetConversation(session) {
22
+ const storedId = session.storageThreadId;
23
+ session.serverThreadId = null;
24
+ session.storageThreadId = null;
25
+ session.orphanIds = [];
26
+ session.createdAt = null;
27
+ session.lastSyncedAt = 0;
28
+ session.dirty = false;
29
+ session.loadRequest += 1;
30
+ session.isStreaming = false;
31
+ session.foreignInFlight = false;
32
+ return storedId;
33
+ }
34
+ export function nextMessageId(session) {
35
+ session.idCounter += 1;
36
+ return `msg-${session.idCounter}-${Date.now()}-${session.idSuffix}`;
37
+ }
38
+ /** Whether `messageId` was minted by this session rather than by another tab. */
39
+ export function isOwnMessage(session, messageId) {
40
+ return messageId.endsWith(`-${session.idSuffix}`);
41
+ }
42
+ /** Makes a stored conversation the one this session continues. */
43
+ export function adoptStoredChat(session, chat) {
44
+ session.storageThreadId = chat.threadId;
45
+ session.createdAt = chat.createdAt;
46
+ session.serverThreadId = isLocalThreadId(chat.threadId) ? null : chat.threadId;
47
+ session.idCounter = nextMessageCounter(chat.messages);
48
+ session.lastSyncedAt = chat.updatedAt;
49
+ }
50
+ /** A copy that keeps the conversation `session` holds while `session` itself moves on. */
51
+ export function forkSession(session) {
52
+ return { ...session, orphanIds: [...session.orphanIds] };
53
+ }
54
+ /** Makes `session` continue the conversation `from` kept, showing `messages`. */
55
+ export function adoptSession(session, from, messages) {
56
+ session.serverThreadId = from.serverThreadId;
57
+ session.storageThreadId = from.storageThreadId;
58
+ session.orphanIds = [...from.orphanIds];
59
+ session.createdAt = from.createdAt;
60
+ session.lastSyncedAt = from.lastSyncedAt;
61
+ session.idCounter = nextMessageCounter(messages);
62
+ }
63
+ /** Builds the record to write and moves the session onto its key; `staleIds` are the records to retire once stored. */
64
+ export function prepareSnapshot(session, messages, now = Date.now()) {
65
+ const previousId = session.storageThreadId;
66
+ const threadId = session.serverThreadId ?? previousId ?? createLocalThreadId();
67
+ const staleIds = [previousId !== threadId ? previousId : null, ...session.orphanIds].filter((id) => id !== null && id !== threadId);
68
+ // Strictly increasing so other tabs read a same-millisecond save as newer.
69
+ const updatedAt = Math.max(now, session.lastSyncedAt + 1);
70
+ const createdAt = session.createdAt ?? updatedAt;
71
+ session.storageThreadId = threadId;
72
+ session.orphanIds = [];
73
+ session.createdAt = createdAt;
74
+ session.lastSyncedAt = updatedAt;
75
+ return {
76
+ staleIds: Array.from(new Set(staleIds)),
77
+ snapshot: {
78
+ version: PERSISTED_CHAT_VERSION,
79
+ threadId,
80
+ messages: compactMessages(messages, session.normalizeItem),
81
+ createdAt,
82
+ updatedAt,
83
+ owner: getTabId(),
84
+ },
85
+ };
86
+ }