@constructor-io/constructorio-ui-asa 1.7.2 → 1.9.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 +17 -17
  3. package/lib/cjs/components/Button/Button.js +22 -3
  4. package/lib/cjs/components/Chat/AiMessage.js +2 -3
  5. package/lib/cjs/components/Chat/Chat.js +50 -9
  6. package/lib/cjs/components/CioAsa/CioAsa.js +6 -2
  7. package/lib/cjs/components/CioAsaProvider/CioAsaProvider.js +44 -3
  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 +119 -159
  17. package/lib/cjs/hooks/useAsaTracking.js +33 -18
  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/Button/Button.js +26 -3
  30. package/lib/mjs/components/Chat/AiMessage.js +2 -3
  31. package/lib/mjs/components/Chat/Chat.js +48 -9
  32. package/lib/mjs/components/CioAsa/CioAsa.js +6 -2
  33. package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +44 -4
  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 +113 -156
  43. package/lib/mjs/hooks/useAsaTracking.js +42 -17
  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/Button/Button.d.ts +3 -2
  56. package/lib/types/components/Chat/Chat.d.ts +20 -2
  57. package/lib/types/components/CioAsa/CioAsa.d.ts +1 -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 +6 -2
  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 +3 -2
  69. package/lib/types/types.d.ts +170 -5
  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 +2 -2
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sharedLocalStoragePersistence = exports.createLocalStoragePersistence = exports.clearPersistedConversations = exports.persistenceNamespace = exports.storageAreaFor = exports.shopperId = exports.isGuest = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const chatThreads_1 = require("./chatThreads");
6
+ const chatRecordValidation_1 = tslib_1.__importDefault(require("./chatRecordValidation"));
7
+ /** Keeps the last `maxTurns` user/assistant pairs, always starting on a user message. */
8
+ function trimToTurns(messages, maxTurns) {
9
+ if (maxTurns <= 0)
10
+ return [];
11
+ let trimmed = Number.isFinite(maxTurns) ? messages.slice(-maxTurns * 2) : messages;
12
+ while (trimmed.length && trimmed[0].role !== 'user')
13
+ trimmed = trimmed.slice(1);
14
+ return trimmed;
15
+ }
16
+ // Web Locks where available; elsewhere the cycle runs synchronously.
17
+ function withStorageLock(name, fn) {
18
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
19
+ const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
20
+ if (!(locks === null || locks === void 0 ? void 0 : locks.request)) {
21
+ fn();
22
+ return;
23
+ }
24
+ let ran = false;
25
+ try {
26
+ yield locks.request(name, () => tslib_1.__awaiter(this, void 0, void 0, function* () {
27
+ ran = true;
28
+ fn();
29
+ }));
30
+ }
31
+ catch (_a) {
32
+ if (!ran)
33
+ fn();
34
+ }
35
+ });
36
+ }
37
+ /** The storage to use, or `null` when none is available (server, or access blocked). */
38
+ function resolveStorage(storage, area) {
39
+ if (storage)
40
+ return storage;
41
+ try {
42
+ if (typeof window === 'undefined')
43
+ return null;
44
+ return area === 'session' ? window.sessionStorage : window.localStorage;
45
+ }
46
+ catch (_a) {
47
+ return null;
48
+ }
49
+ }
50
+ /** No user id, or an empty one: the conversation belongs to a guest. */
51
+ const isGuest = (userId) => userId == null || userId === '';
52
+ exports.isGuest = isGuest;
53
+ /** The id the client and storage use: `undefined` for a guest (no id, `null` or empty). */
54
+ const shopperId = (userId) => (0, exports.isGuest)(userId) ? undefined : String(userId);
55
+ exports.shopperId = shopperId;
56
+ /** Guests live in `sessionStorage`, so their history ends with the tab; shoppers keep theirs in `localStorage`. */
57
+ function storageAreaFor(userId) {
58
+ return (0, exports.isGuest)(userId) ? 'session' : 'local';
59
+ }
60
+ exports.storageAreaFor = storageAreaFor;
61
+ /** Namespace the provider stores under: api key, domain and, for a signed-in shopper, the user id. */
62
+ function persistenceNamespace(parts) {
63
+ var _a;
64
+ return [parts.apiKey, (_a = parts.domain) !== null && _a !== void 0 ? _a : 'default', (0, exports.shopperId)(parts.userId)]
65
+ .filter((part) => part !== undefined)
66
+ .map(encodeURIComponent)
67
+ .join(':');
68
+ }
69
+ exports.persistenceNamespace = persistenceNamespace;
70
+ function storageKeyFor(key, namespace) {
71
+ return [key, `v${chatThreads_1.PERSISTED_CHAT_VERSION}`, namespace].filter(Boolean).join(':');
72
+ }
73
+ const emptyStored = () => ({
74
+ version: chatThreads_1.PERSISTED_CHAT_VERSION,
75
+ threads: {},
76
+ deleted: {},
77
+ });
78
+ function parseStored(raw) {
79
+ var _a;
80
+ if (!raw)
81
+ return emptyStored();
82
+ try {
83
+ const parsed = JSON.parse(raw);
84
+ if ((parsed === null || parsed === void 0 ? void 0 : parsed.version) !== chatThreads_1.PERSISTED_CHAT_VERSION || !parsed.threads)
85
+ return emptyStored();
86
+ const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => (0, chatRecordValidation_1.default)(chat)));
87
+ const deleted = Object.fromEntries(Object.entries((_a = parsed.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => typeof at === 'number'));
88
+ return { version: chatThreads_1.PERSISTED_CHAT_VERSION, threads, deleted };
89
+ }
90
+ catch (_b) {
91
+ return emptyStored();
92
+ }
93
+ }
94
+ function readItem(storage, key) {
95
+ try {
96
+ return storage.getItem(key);
97
+ }
98
+ catch (_a) {
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
104
+ * follow. Resolves once the deletion is recorded.
105
+ */
106
+ function clearPersistedConversations(options) {
107
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
108
+ const { apiKey, domain = 'chatbot', userId, storage: storageOption } = options;
109
+ const storage = resolveStorage(storageOption, storageAreaFor(userId));
110
+ if (!storage)
111
+ return;
112
+ const key = storageKeyFor(chatThreads_1.DEFAULT_PERSISTENCE_KEY, persistenceNamespace({ apiKey, domain, userId }));
113
+ let cleared = false;
114
+ // Under the store's lock, and as tombstones rather than a bare removal: a save still queued in
115
+ // some tab, holding a copy of a thread, must find it deleted instead of writing it back.
116
+ yield withStorageLock(key, () => {
117
+ var _a;
118
+ try {
119
+ const raw = readItem(storage, key);
120
+ if (raw === null) {
121
+ cleared = true;
122
+ return;
123
+ }
124
+ const current = parseStored(raw);
125
+ const now = Date.now();
126
+ const deleted = Object.keys(current.threads).reduce((all, threadId) => (Object.assign(Object.assign({}, all), { [threadId]: now })), (_a = current.deleted) !== null && _a !== void 0 ? _a : {});
127
+ if (Object.keys(deleted).length === 0)
128
+ storage.removeItem(key);
129
+ else
130
+ storage.setItem(key, JSON.stringify(Object.assign(Object.assign({}, emptyStored()), { deleted })));
131
+ cleared = true;
132
+ }
133
+ catch (_b) {
134
+ /* storage unavailable */
135
+ }
136
+ });
137
+ if (!cleared)
138
+ return;
139
+ // Other tabs get a native storage event; this tab does not, so dispatch one for a mounted chat.
140
+ if (typeof window === 'undefined' || typeof StorageEvent === 'undefined')
141
+ return;
142
+ try {
143
+ window.dispatchEvent(new StorageEvent('storage', { key, storageArea: storage }));
144
+ }
145
+ catch (_a) {
146
+ /* a non-Storage `storage` option cannot be attached to the event */
147
+ }
148
+ });
149
+ }
150
+ exports.clearPersistedConversations = clearPersistedConversations;
151
+ /** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
152
+ function createLocalStoragePersistence(options = {}) {
153
+ const { key = chatThreads_1.DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = chatThreads_1.DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
154
+ const storageKey = storageKeyFor(key, namespace);
155
+ const readRaw = (storage) => readItem(storage, storageKey);
156
+ // One sync reads the same key several times; parse and validate the blob once per distinct value.
157
+ let lastParsed = null;
158
+ const parse = (raw) => {
159
+ var _a;
160
+ if (!raw)
161
+ return emptyStored();
162
+ if ((lastParsed === null || lastParsed === void 0 ? void 0 : lastParsed.raw) !== raw)
163
+ lastParsed = { raw, data: parseStored(raw) };
164
+ const cutoff = Date.now() - ttlMs;
165
+ const { data } = lastParsed;
166
+ return {
167
+ version: chatThreads_1.PERSISTED_CHAT_VERSION,
168
+ threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
169
+ deleted: Object.fromEntries(Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => at >= cutoff)),
170
+ };
171
+ };
172
+ const read = () => {
173
+ const storage = resolveStorage(storageOption, storageArea);
174
+ return parse(storage ? readRaw(storage) : null);
175
+ };
176
+ const sortedThreads = (data) => Object.values(data.threads).sort((a, b) => b.updatedAt - a.updatedAt);
177
+ const tryWrite = (storage, data) => {
178
+ try {
179
+ storage.setItem(storageKey, JSON.stringify(data));
180
+ return true;
181
+ }
182
+ catch (_a) {
183
+ return false;
184
+ }
185
+ };
186
+ const remove = (storage) => {
187
+ try {
188
+ storage.removeItem(storageKey);
189
+ }
190
+ catch (_a) {
191
+ /* ignore */
192
+ }
193
+ };
194
+ const isEmpty = (data) => { var _a; return Object.keys(data.threads).length === 0 && Object.keys((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).length === 0; };
195
+ // Retries without tombstones, oldest first: the last thing to shed, see `write`.
196
+ const tryWriteShedding = (storage, data) => {
197
+ var _a, _b;
198
+ if (tryWrite(storage, data))
199
+ return true;
200
+ const oldestFirst = Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {})
201
+ .sort((a, b) => a[1] - b[1])
202
+ .map(([id]) => id);
203
+ let next = data;
204
+ for (let i = 0; i < oldestFirst.length; i += 1) {
205
+ const _c = (_b = next.deleted) !== null && _b !== void 0 ? _b : {}, _d = oldestFirst[i], shed = _c[_d], deleted = tslib_1.__rest(_c, [typeof _d === "symbol" ? _d : _d + ""]);
206
+ next = Object.assign(Object.assign({}, next), { deleted });
207
+ if (tryWrite(storage, next))
208
+ return true;
209
+ }
210
+ return false;
211
+ };
212
+ // A tombstone is the only thing stopping another tab from writing a deleted thread back, while an
213
+ // evicted thread is written back by the tab holding it. So when storage is full, shed other
214
+ // threads oldest first, then the saved thread's oldest turns, and only then tombstones.
215
+ const write = (storage, data, priorityThreadId) => {
216
+ if (isEmpty(data)) {
217
+ remove(storage);
218
+ return;
219
+ }
220
+ if (tryWrite(storage, data))
221
+ return;
222
+ const priority = priorityThreadId ? data.threads[priorityThreadId] : undefined;
223
+ const oldestFirst = sortedThreads(data)
224
+ .filter((chat) => chat.threadId !== (priority === null || priority === void 0 ? void 0 : priority.threadId))
225
+ .reverse();
226
+ let next = data;
227
+ for (let i = 0; i < oldestFirst.length; i += 1) {
228
+ const _a = next.threads, _b = oldestFirst[i].threadId, evicted = _a[_b], threads = tslib_1.__rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
229
+ next = Object.assign(Object.assign({}, next), { threads });
230
+ if (tryWrite(storage, next))
231
+ return;
232
+ }
233
+ if (!priority) {
234
+ // Not even the tombstones fit without the threads; keep the threads rather than wipe them.
235
+ if (Object.keys(data.threads).length === 0)
236
+ remove(storage);
237
+ else
238
+ tryWriteShedding(storage, data);
239
+ return;
240
+ }
241
+ let chat = priority;
242
+ while (chat.messages.length > 2) {
243
+ chat = Object.assign(Object.assign({}, chat), { messages: trimToTurns(chat.messages.slice(2), maxTurns) });
244
+ next = Object.assign(Object.assign({}, next), { threads: Object.assign(Object.assign({}, next.threads), { [chat.threadId]: chat }) });
245
+ if (tryWrite(storage, next))
246
+ return;
247
+ }
248
+ if (tryWriteShedding(storage, next))
249
+ return;
250
+ // Nothing fits: drop the stored copy too, so no in-flight record is left behind.
251
+ const _c = data.threads, _d = priority.threadId, dropped = _c[_d], rest = tslib_1.__rest(_c, [typeof _d === "symbol" ? _d : _d + ""]);
252
+ const others = Object.assign(Object.assign({}, data), { threads: rest });
253
+ if (isEmpty(others))
254
+ remove(storage);
255
+ else
256
+ tryWriteShedding(storage, others);
257
+ };
258
+ // Redo the merge if another tab wrote between our read and this write.
259
+ const MAX_COMMIT_ATTEMPTS = 3;
260
+ const transact = (mutate, priorityThreadId) => {
261
+ const storage = resolveStorage(storageOption, storageArea);
262
+ if (!storage)
263
+ return;
264
+ for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
265
+ const raw = readRaw(storage);
266
+ const next = mutate(parse(raw));
267
+ if (!next)
268
+ return;
269
+ if (readRaw(storage) === raw) {
270
+ write(storage, next, priorityThreadId);
271
+ return;
272
+ }
273
+ }
274
+ // The key kept changing under us: skip rather than overwrite another tab's turns. Without
275
+ // Web Locks this check is best effort; a write between the compare and the set can still win.
276
+ };
277
+ /** The record to store once `chat` is merged in, or `null` to leave storage alone. */
278
+ const mergeThread = (current, chat) => {
279
+ var _a, _b, _c, _d;
280
+ const now = Date.now();
281
+ const createdAt = (_a = chat.createdAt) !== null && _a !== void 0 ? _a : now;
282
+ const deletedAt = (_b = current.deleted) === null || _b === void 0 ? void 0 : _b[chat.threadId];
283
+ if (deletedAt !== undefined && deletedAt >= createdAt)
284
+ return null;
285
+ const stored = current.threads[chat.threadId];
286
+ const incomingAt = (_c = chat.updatedAt) !== null && _c !== void 0 ? _c : now;
287
+ // An older snapshot may add turns but must not regress ones already settled.
288
+ const stale = Boolean(stored) && incomingAt < stored.updatedAt;
289
+ // Merging keeps turns other tabs added. The tab that wrote the stored record is instead
290
+ // writing a newer full view of it, and may have dropped a message (an aborted reply), so
291
+ // merging its own older copy back would resurrect what it just removed.
292
+ const ownsStored = (stored === null || stored === void 0 ? void 0 : stored.owner) !== undefined && chat.owner !== undefined && stored.owner === chat.owner;
293
+ const replace = !stored || (ownsStored && !stale);
294
+ const messages = replace ? chat.messages : (0, chatThreads_1.mergeMessages)(stored.messages, chat.messages, stale);
295
+ const base = stale ? stored : chat;
296
+ // A record holding turns its owner has not seen is nobody's full view, so the next save merges too.
297
+ const absorbed = !replace && messages.length > base.messages.length;
298
+ const merged = Object.assign(Object.assign({}, base), { owner: absorbed ? undefined : base.owner, version: chatThreads_1.PERSISTED_CHAT_VERSION, threadId: chat.threadId, messages: trimToTurns(messages, maxTurns), createdAt, updatedAt: Math.max(incomingAt, (_d = stored === null || stored === void 0 ? void 0 : stored.updatedAt) !== null && _d !== void 0 ? _d : 0) });
299
+ let next = Object.assign(Object.assign({}, current), { threads: Object.assign(Object.assign({}, current.threads), { [chat.threadId]: merged }) });
300
+ if (Number.isFinite(maxThreads)) {
301
+ const kept = sortedThreads(next).slice(0, Math.max(0, maxThreads));
302
+ next = Object.assign(Object.assign({}, next), { threads: Object.fromEntries(kept.map((t) => [t.threadId, t])) });
303
+ if (!next.threads[chat.threadId])
304
+ return null;
305
+ }
306
+ return next;
307
+ };
308
+ const retire = (data, staleId) => {
309
+ const _a = data.threads, _b = staleId, retired = _a[_b], threads = tslib_1.__rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
310
+ return Object.assign(Object.assign({}, data), { threads, deleted: Object.assign(Object.assign({}, data.deleted), { [staleId]: Date.now() }) });
311
+ };
312
+ return {
313
+ listThreads() {
314
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
315
+ const now = Date.now();
316
+ return sortedThreads(read()).map((chat) => ({
317
+ threadId: chat.threadId,
318
+ title: (0, chatThreads_1.getThreadTitle)(chat.messages),
319
+ createdAt: chat.createdAt,
320
+ updatedAt: chat.updatedAt,
321
+ inFlight: (0, chatThreads_1.isThreadStreaming)(chat, now),
322
+ }));
323
+ });
324
+ },
325
+ getThread(threadId) {
326
+ var _a;
327
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
328
+ return (_a = read().threads[threadId]) !== null && _a !== void 0 ? _a : null;
329
+ });
330
+ },
331
+ isThreadDeleted(threadId) {
332
+ var _a;
333
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
334
+ // Deletes leave tombstones, so a missing key means the store was cleared from outside.
335
+ const storage = resolveStorage(storageOption, storageArea);
336
+ if (!storage || readRaw(storage) === null)
337
+ return true;
338
+ return ((_a = read().deleted) === null || _a === void 0 ? void 0 : _a[threadId]) !== undefined;
339
+ });
340
+ },
341
+ saveThread(chat) {
342
+ return withStorageLock(storageKey, () => transact((current) => mergeThread(current, chat), chat.threadId));
343
+ },
344
+ saveThreadSync(chat, staleIds = []) {
345
+ // No Web Lock: its callback is a task, and tasks do not run while the document unloads.
346
+ transact((current) => {
347
+ const merged = mergeThread(current, chat);
348
+ if (!merged)
349
+ return null;
350
+ return staleIds
351
+ .filter((id) => id !== chat.threadId)
352
+ .reduce((next, id) => retire(next, id), merged);
353
+ }, chat.threadId);
354
+ },
355
+ deleteThread(threadId) {
356
+ return withStorageLock(storageKey, () => transact((current) => retire(current, threadId)));
357
+ },
358
+ subscribe(listener) {
359
+ if (typeof window === 'undefined')
360
+ return () => { };
361
+ const onStorage = (event) => {
362
+ const area = resolveStorage(storageOption, storageArea);
363
+ if (area && event.storageArea && event.storageArea !== area)
364
+ return;
365
+ if (event.key === null || event.key === storageKey)
366
+ listener();
367
+ };
368
+ window.addEventListener('storage', onStorage);
369
+ return () => window.removeEventListener('storage', onStorage);
370
+ },
371
+ };
372
+ }
373
+ exports.createLocalStoragePersistence = createLocalStoragePersistence;
374
+ const sharedStores = new Map();
375
+ /**
376
+ * One store per namespace and area for the page, so a chat mounted again finds the answers still
377
+ * streaming into it. Not shared on the server, where the map would outlive the request.
378
+ */
379
+ function sharedLocalStoragePersistence(namespace, storageArea) {
380
+ if (typeof window === 'undefined')
381
+ return createLocalStoragePersistence({ namespace, storageArea });
382
+ const id = `${storageArea}:${namespace}`;
383
+ const existing = sharedStores.get(id);
384
+ if (existing)
385
+ return existing;
386
+ const store = createLocalStoragePersistence({ namespace, storageArea });
387
+ sharedStores.set(id, store);
388
+ return store;
389
+ }
390
+ exports.sharedLocalStoragePersistence = sharedLocalStoragePersistence;
391
+ exports.default = createLocalStoragePersistence;
@@ -21,6 +21,7 @@ const defaultTranslations = {
21
21
  'CioAsa.results.saleBadge': 'Sale',
22
22
  'CioAsa.refinement.ariaLabel': 'Refine your results',
23
23
  'CioAsa.error.message': "I can't assist you with that request.",
24
+ 'CioAsa.error.interrupted': 'The answer was interrupted. Please send your question again.',
24
25
  };
25
26
  function translate(key, translations) {
26
27
  var _a;
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.7.2';
3
+ exports.default = '1.9.0';
@@ -1,8 +1,31 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import { Button as CioButton } from '@constructor-io/constructorio-ui-components';
3
3
  import { ChatBubbleDarkIcon, ChatBubbleLightIcon } from '../icons';
4
- export default function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', }) {
5
- return (React.createElement(CioButton, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: onClick },
4
+ import { useCioAsaContext } from '../../hooks/useCioAsaContext';
5
+ import useAsaTracking from '../../hooks/useAsaTracking';
6
+ const AGENT_BUTTON_CLICK_MODE = 'chat';
7
+ export default function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', positionOnPage, pageType, instanceId, }) {
8
+ const context = useCioAsaContext();
9
+ const agentDomain = context?.staticRequestConfigs?.domain;
10
+ const tracking = useAsaTracking({
11
+ tracker: context?.cioClient?.tracker ?? undefined,
12
+ section: context?.section,
13
+ });
14
+ const handleClick = useCallback(() => {
15
+ if (agentDomain) {
16
+ const payload = {
17
+ mode: AGENT_BUTTON_CLICK_MODE,
18
+ agentDomain,
19
+ ...(positionOnPage && { positionOnPage }),
20
+ ...(pageType && { pageType }),
21
+ ...(instanceId && { instanceId }),
22
+ };
23
+ tracking.trackAgentButtonClick(payload);
24
+ context?.callbacks?.onAgentButtonClick?.(payload);
25
+ }
26
+ onClick?.();
27
+ }, [agentDomain, positionOnPage, pageType, instanceId, tracking, context, onClick]);
28
+ return (React.createElement(CioButton, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: handleClick },
6
29
  theme === 'dark' ? React.createElement(ChatBubbleDarkIcon, null) : React.createElement(ChatBubbleLightIcon, null),
7
30
  React.createElement("span", { className: 'cio-asa-button__label' }, label)));
8
31
  }
@@ -8,9 +8,8 @@ export default function AiMessage({ message, componentOverrides, translations })
8
8
  const hasGroups = !!message.groups?.length;
9
9
  // On error, fall back to the (translatable) error message when the agent
10
10
  // returned no partial text of its own.
11
- const text = isError
12
- ? message.text || translate('CioAsa.error.message', translations)
13
- : message.text || '';
11
+ const errorKey = message.interrupted ? 'CioAsa.error.interrupted' : 'CioAsa.error.message';
12
+ const text = isError ? message.text || translate(errorKey, translations) : message.text || '';
14
13
  const hasText = text.length > 0;
15
14
  const loaderRenderProps = { translations };
16
15
  const textRenderProps = { text };
@@ -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,54 @@ 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, initialPrompt, 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
47
  useImperativeHandle(ref, () => ({
35
48
  abort,
36
49
  clearHistory,
37
- }));
50
+ newThread,
51
+ switchThread,
52
+ sendMessage: (text, options) => sendMessage(text, 'external', options),
53
+ }), [abort, clearHistory, newThread, switchThread, sendMessage]);
54
+ // Sent from a timer so StrictMode's mount, unmount, mount cannot cancel the stream it starts.
55
+ const initialPromptRef = useRef(initialPrompt);
38
56
  useEffect(() => {
57
+ const prompt = initialPromptRef.current;
58
+ if (isHydrating || !prompt)
59
+ return undefined;
60
+ const timer = setTimeout(() => {
61
+ initialPromptRef.current = undefined;
62
+ sendMessage(prompt, 'external');
63
+ });
64
+ return () => clearTimeout(timer);
65
+ }, [isHydrating, sendMessage]);
66
+ const hasPersistence = Boolean(useCioAsaContext()?.persistence);
67
+ const onThreadsChangeRef = useLatest(onThreadsChange);
68
+ useEffect(() => {
69
+ if (!hasPersistence || isHydrating)
70
+ return;
71
+ onThreadsChangeRef.current?.(threads, activeThreadId);
72
+ }, [threads, activeThreadId, isHydrating, hasPersistence, onThreadsChangeRef]);
73
+ useEffect(() => {
74
+ if (isHydrating)
75
+ return;
39
76
  const root = isWelcome ? containerRef.current : chatViewRef.current;
40
77
  const input = root?.querySelector('input');
41
78
  if (input) {
@@ -48,15 +85,17 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
48
85
  input.focus();
49
86
  }
50
87
  }
51
- }, [isWelcome]);
88
+ }, [isWelcome, isHydrating]);
52
89
  useFocusTrap(containerRef, { onEscape: onClose, trapFocus: isModal });
53
90
  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
91
  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 }))))));
92
+ React.createElement("div", { className: 'cio-asa-chat-body' },
93
+ showWelcome && (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--welcome' },
94
+ React.createElement(WelcomeScreen, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides?.welcomeScreen }))),
95
+ showChatFrame && (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
96
+ React.createElement(ChatHeader, { onClose: onClose, translations: translations, componentOverrides: componentOverrides?.header }),
97
+ 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 }),
98
+ React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming || isHydrating, isStreaming: isStreaming, onAbort: canAbort ? abort : undefined, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides?.input }))))));
60
99
  });
61
100
  Chat.displayName = 'Chat';
62
101
  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,