@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
@@ -0,0 +1,219 @@
1
+ /** Stored record format; records with another version are ignored on read. */
2
+ export const PERSISTED_CHAT_VERSION = 1;
3
+ /** Prefix of the `localStorage` key; the api key, domain and user id are appended to it. */
4
+ export const DEFAULT_PERSISTENCE_KEY = 'cio-asa:chat';
5
+ /** One week, matching how long the agent itself remembers a thread. */
6
+ export const DEFAULT_PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7
+ /** How long a stored answer written by another tab is still shown as "typing" before it is settled. */
8
+ export const IN_FLIGHT_GRACE_MS = 60 * 1000;
9
+ /** Marks client-side thread ids, used before the server has assigned one. Never sent to the agent. */
10
+ const LOCAL_THREAD_PREFIX = 'local-';
11
+ /** Thread titles are the first question, cut to this many characters. */
12
+ const TITLE_MAX_LENGTH = 80;
13
+ let fallbackCounter = 0;
14
+ /** Random token for the ids that must not collide between tabs: threads, tabs and messages. */
15
+ export function randomId() {
16
+ if (typeof crypto !== 'undefined') {
17
+ if (typeof crypto.randomUUID === 'function')
18
+ return crypto.randomUUID();
19
+ if (typeof crypto.getRandomValues === 'function') {
20
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
21
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
22
+ }
23
+ }
24
+ fallbackCounter += 1;
25
+ return `${Date.now().toString(36)}-${fallbackCounter.toString(36)}`;
26
+ }
27
+ /** Client-side thread id for a conversation the server has not named yet. */
28
+ export function createLocalThreadId() {
29
+ return `${LOCAL_THREAD_PREFIX}${randomId()}`;
30
+ }
31
+ const TAB_ID_KEY = 'cio-asa:tab';
32
+ let tabId;
33
+ /** Whether this page load is a reload, as opposed to a new, duplicated or restored tab. */
34
+ function isReload() {
35
+ try {
36
+ const [entry] = performance.getEntriesByType('navigation');
37
+ return entry?.type === 'reload';
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Per-tab id: survives a reload, differs between tabs, `undefined` on the server. A duplicated tab
45
+ * copies `sessionStorage`, so the id is read back on a reload only, and is kept there only while
46
+ * the page is hidden.
47
+ */
48
+ export function getTabId() {
49
+ if (tabId)
50
+ return tabId;
51
+ if (typeof window === 'undefined')
52
+ return undefined;
53
+ const id = randomId();
54
+ tabId = id;
55
+ try {
56
+ const { sessionStorage } = window;
57
+ tabId = (isReload() && sessionStorage.getItem(TAB_ID_KEY)) || id;
58
+ sessionStorage.removeItem(TAB_ID_KEY);
59
+ const claimed = tabId;
60
+ window.addEventListener('pagehide', () => {
61
+ try {
62
+ sessionStorage.setItem(TAB_ID_KEY, claimed);
63
+ }
64
+ catch {
65
+ /* ignore */
66
+ }
67
+ });
68
+ window.addEventListener('pageshow', (event) => {
69
+ if (!event.persisted)
70
+ return;
71
+ try {
72
+ sessionStorage.removeItem(TAB_ID_KEY);
73
+ }
74
+ catch {
75
+ /* ignore */
76
+ }
77
+ });
78
+ }
79
+ catch {
80
+ /* sessionStorage blocked: the id lasts for this page only */
81
+ }
82
+ return tabId;
83
+ }
84
+ /** Whether `threadId` was made by `createLocalThreadId` rather than by the server. */
85
+ export function isLocalThreadId(threadId) {
86
+ return typeof threadId === 'string' && threadId.startsWith(LOCAL_THREAD_PREFIX);
87
+ }
88
+ /** Title for a thread list: the first user message, truncated with an ellipsis. */
89
+ export function getThreadTitle(messages) {
90
+ const first = messages.find((m) => m.role === 'user')?.text ?? '';
91
+ return first.length > TITLE_MAX_LENGTH ? `${first.slice(0, TITLE_MAX_LENGTH - 1)}…` : first;
92
+ }
93
+ /** Counter to continue message ids from after a restore, so new ids never collide with restored ones. */
94
+ export function nextMessageCounter(messages) {
95
+ return messages.reduce((max, m) => {
96
+ const match = /^msg-(\d+)-/.exec(m.id);
97
+ return match ? Math.max(max, Number(match[1])) : max;
98
+ }, messages.length);
99
+ }
100
+ /** Whether the latest answer in `messages` has not finished streaming. */
101
+ export function isInFlight(messages) {
102
+ const last = messages[messages.length - 1];
103
+ return last?.status === 'loading' || last?.status === 'streaming';
104
+ }
105
+ /** Settles answers stored mid-stream: `done` when they have content, an interrupted `error` otherwise. */
106
+ export function normalizeHydratedMessages(messages) {
107
+ return messages.map((m) => {
108
+ if (m.status !== 'loading' && m.status !== 'streaming')
109
+ return m;
110
+ const hasContent = Boolean(m.text) || Boolean(m.groups?.length) || Boolean(m.refinement);
111
+ return hasContent ? { ...m, status: 'done' } : { ...m, status: 'error', interrupted: true };
112
+ });
113
+ }
114
+ /** Messages as stored: raw items replaced by the cards `normalize` makes; a restored group keeps its cards. */
115
+ export function compactMessages(messages, normalize) {
116
+ return messages.map((m) => m.groups?.length
117
+ ? {
118
+ ...m,
119
+ groups: m.groups.map((g) => ({
120
+ ...g,
121
+ searchResults: [],
122
+ products: g.products ?? g.searchResults.map(normalize),
123
+ })),
124
+ }
125
+ : m);
126
+ }
127
+ const isUnsettled = (m) => m.status === 'loading' || m.status === 'streaming';
128
+ /**
129
+ * Both lists in stored order; a shared message takes the incoming version unless `preferStored`.
130
+ * A settled incoming version still replaces a stored one mid-stream: it is the later one by definition.
131
+ */
132
+ export function mergeMessages(stored, incoming, preferStored = false) {
133
+ const incomingById = new Map(incoming.map((m) => [m.id, m]));
134
+ const storedIds = new Set(stored.map((m) => m.id));
135
+ return [
136
+ ...stored.map((m) => {
137
+ const update = incomingById.get(m.id);
138
+ if (!update)
139
+ return m;
140
+ if (!preferStored)
141
+ return update;
142
+ return isUnsettled(m) && !isUnsettled(update) ? update : m;
143
+ }),
144
+ ...incoming.filter((m) => !storedIds.has(m.id)),
145
+ ];
146
+ }
147
+ /**
148
+ * Saves `snapshot`, then deletes `staleIds` once the new record is read back. Stores may fail
149
+ * without throwing, so both steps are checked by reading: `saved` is whether the snapshot landed,
150
+ * `leftover` the stale ids still stored.
151
+ */
152
+ export async function saveThreadAndRetireStale(store, snapshot, staleIds) {
153
+ try {
154
+ await store.saveThread(snapshot);
155
+ }
156
+ catch {
157
+ return { saved: false, leftover: staleIds };
158
+ }
159
+ const persisted = await store.getThread(snapshot.threadId).catch(() => null);
160
+ if (!persisted || persisted.updatedAt < snapshot.updatedAt) {
161
+ return { saved: false, leftover: staleIds };
162
+ }
163
+ const retired = await Promise.all(staleIds.map(async (id) => {
164
+ await store.deleteThread(id).catch(() => { });
165
+ return (await store.getThread(id).catch(() => null)) === null;
166
+ }));
167
+ return { saved: true, leftover: staleIds.filter((_, i) => !retired[i]) };
168
+ }
169
+ /** Finds the server-keyed record another tab re-keyed a local thread into, by its first message. */
170
+ export async function findRekeyedThread(store, firstMessageId) {
171
+ if (!firstMessageId)
172
+ return null;
173
+ const list = await store.listThreads();
174
+ const candidates = list.filter((t) => !isLocalThreadId(t.threadId));
175
+ const loaded = await Promise.all(candidates.map((t) => store.getThread(t.threadId)));
176
+ return loaded.find((c) => c?.messages[0]?.id === firstMessageId) ?? null;
177
+ }
178
+ /** Whether a stored thread still reads as streaming: this tab's own always, another tab's only within the grace. */
179
+ export function isThreadStreaming(chat, now = Date.now()) {
180
+ if (!isInFlight(chat.messages))
181
+ return false;
182
+ if (chat.owner !== undefined && chat.owner === getTabId())
183
+ return true;
184
+ return now - chat.updatedAt < IN_FLIGHT_GRACE_MS;
185
+ }
186
+ /** Ms left to show a stored answer as streaming in another tab; `0` when it should be settled. */
187
+ export function foreignStreamRemainingMs(chat, now = Date.now()) {
188
+ if (!isInFlight(chat.messages))
189
+ return 0;
190
+ if (chat.owner !== undefined && chat.owner === getTabId())
191
+ return 0;
192
+ return Math.max(0, IN_FLIGHT_GRACE_MS - (now - chat.updatedAt));
193
+ }
194
+ /**
195
+ * Moves every thread of `from` into `to`; copies of the chat on screen are not copied, its owner
196
+ * saves it. A source record is deleted only once `to` holds it after every copy was made, so one
197
+ * copy evicting another, or a save that did not land, leaves the source in place.
198
+ */
199
+ export async function moveThreads(from, to, onScreen = {}) {
200
+ const { threadIds = [], firstMessageId } = onScreen;
201
+ const summaries = await from.listThreads();
202
+ const chats = (await Promise.all(summaries.map((t) => from.getThread(t.threadId)))).filter((chat) => chat !== null);
203
+ const isShown = (chat) => threadIds.includes(chat.threadId) ||
204
+ (firstMessageId !== undefined && chat.messages[0]?.id === firstMessageId);
205
+ const toCopy = chats.filter((chat) => !isShown(chat));
206
+ await Promise.all(toCopy.map((chat) => to.saveThread(chat)));
207
+ const targetList = await to.listThreads();
208
+ const target = new Map((await Promise.all(targetList.map((t) => to.getThread(t.threadId))))
209
+ .filter((chat) => chat !== null)
210
+ .map((chat) => [chat.threadId, chat]));
211
+ const shownSaved = Array.from(target.values()).some(isShown);
212
+ const moved = chats.filter((chat) => {
213
+ if (isShown(chat))
214
+ return shownSaved;
215
+ const copy = target.get(chat.threadId);
216
+ return copy !== undefined && copy.updatedAt >= chat.updatedAt;
217
+ });
218
+ await Promise.all(moved.map((chat) => from.deleteThread(chat.threadId)));
219
+ }
@@ -0,0 +1,7 @@
1
+ /** `options` is private in the client's typings; read it here only, so a client bump fails a test, not storage keys. */
2
+ export function readClientOptions(client) {
3
+ const { options } = (client ?? {});
4
+ if (!options || typeof options !== 'object')
5
+ return undefined;
6
+ return options;
7
+ }
@@ -0,0 +1,373 @@
1
+ import { DEFAULT_PERSISTENCE_KEY, DEFAULT_PERSISTENCE_TTL_MS, PERSISTED_CHAT_VERSION, getThreadTitle, isThreadStreaming, mergeMessages, } from './chatThreads';
2
+ import isPersistedChat from './chatRecordValidation';
3
+ /** Keeps the last `maxTurns` user/assistant pairs, always starting on a user message. */
4
+ function trimToTurns(messages, maxTurns) {
5
+ if (maxTurns <= 0)
6
+ return [];
7
+ let trimmed = Number.isFinite(maxTurns) ? messages.slice(-maxTurns * 2) : messages;
8
+ while (trimmed.length && trimmed[0].role !== 'user')
9
+ trimmed = trimmed.slice(1);
10
+ return trimmed;
11
+ }
12
+ // Web Locks where available; elsewhere the cycle runs synchronously.
13
+ async function withStorageLock(name, fn) {
14
+ const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
15
+ if (!locks?.request) {
16
+ fn();
17
+ return;
18
+ }
19
+ let ran = false;
20
+ try {
21
+ await locks.request(name, async () => {
22
+ ran = true;
23
+ fn();
24
+ });
25
+ }
26
+ catch {
27
+ if (!ran)
28
+ fn();
29
+ }
30
+ }
31
+ /** The storage to use, or `null` when none is available (server, or access blocked). */
32
+ function resolveStorage(storage, area) {
33
+ if (storage)
34
+ return storage;
35
+ try {
36
+ if (typeof window === 'undefined')
37
+ return null;
38
+ return area === 'session' ? window.sessionStorage : window.localStorage;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** No user id, or an empty one: the conversation belongs to a guest. */
45
+ export const isGuest = (userId) => userId == null || userId === '';
46
+ /** The id the client and storage use: `undefined` for a guest (no id, `null` or empty). */
47
+ export const shopperId = (userId) => isGuest(userId) ? undefined : String(userId);
48
+ /** Guests live in `sessionStorage`, so their history ends with the tab; shoppers keep theirs in `localStorage`. */
49
+ export function storageAreaFor(userId) {
50
+ return isGuest(userId) ? 'session' : 'local';
51
+ }
52
+ /** Namespace the provider stores under: api key, domain and, for a signed-in shopper, the user id. */
53
+ export function persistenceNamespace(parts) {
54
+ return [parts.apiKey, parts.domain ?? 'default', shopperId(parts.userId)]
55
+ .filter((part) => part !== undefined)
56
+ .map(encodeURIComponent)
57
+ .join(':');
58
+ }
59
+ function storageKeyFor(key, namespace) {
60
+ return [key, `v${PERSISTED_CHAT_VERSION}`, namespace].filter(Boolean).join(':');
61
+ }
62
+ const emptyStored = () => ({
63
+ version: PERSISTED_CHAT_VERSION,
64
+ threads: {},
65
+ deleted: {},
66
+ });
67
+ function parseStored(raw) {
68
+ if (!raw)
69
+ return emptyStored();
70
+ try {
71
+ const parsed = JSON.parse(raw);
72
+ if (parsed?.version !== PERSISTED_CHAT_VERSION || !parsed.threads)
73
+ return emptyStored();
74
+ const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => isPersistedChat(chat)));
75
+ const deleted = Object.fromEntries(Object.entries(parsed.deleted ?? {}).filter(([, at]) => typeof at === 'number'));
76
+ return { version: PERSISTED_CHAT_VERSION, threads, deleted };
77
+ }
78
+ catch {
79
+ return emptyStored();
80
+ }
81
+ }
82
+ function readItem(storage, key) {
83
+ try {
84
+ return storage.getItem(key);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ /**
91
+ * Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
92
+ * follow. Resolves once the deletion is recorded.
93
+ */
94
+ export async function clearPersistedConversations(options) {
95
+ const { apiKey, domain = 'chatbot', userId, storage: storageOption } = options;
96
+ const storage = resolveStorage(storageOption, storageAreaFor(userId));
97
+ if (!storage)
98
+ return;
99
+ const key = storageKeyFor(DEFAULT_PERSISTENCE_KEY, persistenceNamespace({ apiKey, domain, userId }));
100
+ let cleared = false;
101
+ // Under the store's lock, and as tombstones rather than a bare removal: a save still queued in
102
+ // some tab, holding a copy of a thread, must find it deleted instead of writing it back.
103
+ await withStorageLock(key, () => {
104
+ try {
105
+ const raw = readItem(storage, key);
106
+ if (raw === null) {
107
+ cleared = true;
108
+ return;
109
+ }
110
+ const current = parseStored(raw);
111
+ const now = Date.now();
112
+ const deleted = Object.keys(current.threads).reduce((all, threadId) => ({ ...all, [threadId]: now }), current.deleted ?? {});
113
+ if (Object.keys(deleted).length === 0)
114
+ storage.removeItem(key);
115
+ else
116
+ storage.setItem(key, JSON.stringify({ ...emptyStored(), deleted }));
117
+ cleared = true;
118
+ }
119
+ catch {
120
+ /* storage unavailable */
121
+ }
122
+ });
123
+ if (!cleared)
124
+ return;
125
+ // Other tabs get a native storage event; this tab does not, so dispatch one for a mounted chat.
126
+ if (typeof window === 'undefined' || typeof StorageEvent === 'undefined')
127
+ return;
128
+ try {
129
+ window.dispatchEvent(new StorageEvent('storage', { key, storageArea: storage }));
130
+ }
131
+ catch {
132
+ /* a non-Storage `storage` option cannot be attached to the event */
133
+ }
134
+ }
135
+ /** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
136
+ export function createLocalStoragePersistence(options = {}) {
137
+ const { key = DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
138
+ const storageKey = storageKeyFor(key, namespace);
139
+ const readRaw = (storage) => readItem(storage, storageKey);
140
+ // One sync reads the same key several times; parse and validate the blob once per distinct value.
141
+ let lastParsed = null;
142
+ const parse = (raw) => {
143
+ if (!raw)
144
+ return emptyStored();
145
+ if (lastParsed?.raw !== raw)
146
+ lastParsed = { raw, data: parseStored(raw) };
147
+ const cutoff = Date.now() - ttlMs;
148
+ const { data } = lastParsed;
149
+ return {
150
+ version: PERSISTED_CHAT_VERSION,
151
+ threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
152
+ deleted: Object.fromEntries(Object.entries(data.deleted ?? {}).filter(([, at]) => at >= cutoff)),
153
+ };
154
+ };
155
+ const read = () => {
156
+ const storage = resolveStorage(storageOption, storageArea);
157
+ return parse(storage ? readRaw(storage) : null);
158
+ };
159
+ const sortedThreads = (data) => Object.values(data.threads).sort((a, b) => b.updatedAt - a.updatedAt);
160
+ const tryWrite = (storage, data) => {
161
+ try {
162
+ storage.setItem(storageKey, JSON.stringify(data));
163
+ return true;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ };
169
+ const remove = (storage) => {
170
+ try {
171
+ storage.removeItem(storageKey);
172
+ }
173
+ catch {
174
+ /* ignore */
175
+ }
176
+ };
177
+ const isEmpty = (data) => Object.keys(data.threads).length === 0 && Object.keys(data.deleted ?? {}).length === 0;
178
+ // Retries without tombstones, oldest first: the last thing to shed, see `write`.
179
+ const tryWriteShedding = (storage, data) => {
180
+ if (tryWrite(storage, data))
181
+ return true;
182
+ const oldestFirst = Object.entries(data.deleted ?? {})
183
+ .sort((a, b) => a[1] - b[1])
184
+ .map(([id]) => id);
185
+ let next = data;
186
+ for (let i = 0; i < oldestFirst.length; i += 1) {
187
+ const { [oldestFirst[i]]: shed, ...deleted } = next.deleted ?? {};
188
+ next = { ...next, deleted };
189
+ if (tryWrite(storage, next))
190
+ return true;
191
+ }
192
+ return false;
193
+ };
194
+ // A tombstone is the only thing stopping another tab from writing a deleted thread back, while an
195
+ // evicted thread is written back by the tab holding it. So when storage is full, shed other
196
+ // threads oldest first, then the saved thread's oldest turns, and only then tombstones.
197
+ const write = (storage, data, priorityThreadId) => {
198
+ if (isEmpty(data)) {
199
+ remove(storage);
200
+ return;
201
+ }
202
+ if (tryWrite(storage, data))
203
+ return;
204
+ const priority = priorityThreadId ? data.threads[priorityThreadId] : undefined;
205
+ const oldestFirst = sortedThreads(data)
206
+ .filter((chat) => chat.threadId !== priority?.threadId)
207
+ .reverse();
208
+ let next = data;
209
+ for (let i = 0; i < oldestFirst.length; i += 1) {
210
+ const { [oldestFirst[i].threadId]: evicted, ...threads } = next.threads;
211
+ next = { ...next, threads };
212
+ if (tryWrite(storage, next))
213
+ return;
214
+ }
215
+ if (!priority) {
216
+ // Not even the tombstones fit without the threads; keep the threads rather than wipe them.
217
+ if (Object.keys(data.threads).length === 0)
218
+ remove(storage);
219
+ else
220
+ tryWriteShedding(storage, data);
221
+ return;
222
+ }
223
+ let chat = priority;
224
+ while (chat.messages.length > 2) {
225
+ chat = { ...chat, messages: trimToTurns(chat.messages.slice(2), maxTurns) };
226
+ next = { ...next, threads: { ...next.threads, [chat.threadId]: chat } };
227
+ if (tryWrite(storage, next))
228
+ return;
229
+ }
230
+ if (tryWriteShedding(storage, next))
231
+ return;
232
+ // Nothing fits: drop the stored copy too, so no in-flight record is left behind.
233
+ const { [priority.threadId]: dropped, ...rest } = data.threads;
234
+ const others = { ...data, threads: rest };
235
+ if (isEmpty(others))
236
+ remove(storage);
237
+ else
238
+ tryWriteShedding(storage, others);
239
+ };
240
+ // Redo the merge if another tab wrote between our read and this write.
241
+ const MAX_COMMIT_ATTEMPTS = 3;
242
+ const transact = (mutate, priorityThreadId) => {
243
+ const storage = resolveStorage(storageOption, storageArea);
244
+ if (!storage)
245
+ return;
246
+ for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
247
+ const raw = readRaw(storage);
248
+ const next = mutate(parse(raw));
249
+ if (!next)
250
+ return;
251
+ if (readRaw(storage) === raw) {
252
+ write(storage, next, priorityThreadId);
253
+ return;
254
+ }
255
+ }
256
+ // The key kept changing under us: skip rather than overwrite another tab's turns. Without
257
+ // Web Locks this check is best effort; a write between the compare and the set can still win.
258
+ };
259
+ /** The record to store once `chat` is merged in, or `null` to leave storage alone. */
260
+ const mergeThread = (current, chat) => {
261
+ const now = Date.now();
262
+ const createdAt = chat.createdAt ?? now;
263
+ const deletedAt = current.deleted?.[chat.threadId];
264
+ if (deletedAt !== undefined && deletedAt >= createdAt)
265
+ return null;
266
+ const stored = current.threads[chat.threadId];
267
+ const incomingAt = chat.updatedAt ?? now;
268
+ // An older snapshot may add turns but must not regress ones already settled.
269
+ const stale = Boolean(stored) && incomingAt < stored.updatedAt;
270
+ // Merging keeps turns other tabs added. The tab that wrote the stored record is instead
271
+ // writing a newer full view of it, and may have dropped a message (an aborted reply), so
272
+ // merging its own older copy back would resurrect what it just removed.
273
+ const ownsStored = stored?.owner !== undefined && chat.owner !== undefined && stored.owner === chat.owner;
274
+ const replace = !stored || (ownsStored && !stale);
275
+ const messages = replace ? chat.messages : mergeMessages(stored.messages, chat.messages, stale);
276
+ const base = stale ? stored : chat;
277
+ // A record holding turns its owner has not seen is nobody's full view, so the next save merges too.
278
+ const absorbed = !replace && messages.length > base.messages.length;
279
+ const merged = {
280
+ ...base,
281
+ owner: absorbed ? undefined : base.owner,
282
+ version: PERSISTED_CHAT_VERSION,
283
+ threadId: chat.threadId,
284
+ messages: trimToTurns(messages, maxTurns),
285
+ createdAt,
286
+ updatedAt: Math.max(incomingAt, stored?.updatedAt ?? 0),
287
+ };
288
+ let next = {
289
+ ...current,
290
+ threads: { ...current.threads, [chat.threadId]: merged },
291
+ };
292
+ if (Number.isFinite(maxThreads)) {
293
+ const kept = sortedThreads(next).slice(0, Math.max(0, maxThreads));
294
+ next = { ...next, threads: Object.fromEntries(kept.map((t) => [t.threadId, t])) };
295
+ if (!next.threads[chat.threadId])
296
+ return null;
297
+ }
298
+ return next;
299
+ };
300
+ const retire = (data, staleId) => {
301
+ const { [staleId]: retired, ...threads } = data.threads;
302
+ return { ...data, threads, deleted: { ...data.deleted, [staleId]: Date.now() } };
303
+ };
304
+ return {
305
+ async listThreads() {
306
+ const now = Date.now();
307
+ return sortedThreads(read()).map((chat) => ({
308
+ threadId: chat.threadId,
309
+ title: getThreadTitle(chat.messages),
310
+ createdAt: chat.createdAt,
311
+ updatedAt: chat.updatedAt,
312
+ inFlight: isThreadStreaming(chat, now),
313
+ }));
314
+ },
315
+ async getThread(threadId) {
316
+ return read().threads[threadId] ?? null;
317
+ },
318
+ async isThreadDeleted(threadId) {
319
+ // Deletes leave tombstones, so a missing key means the store was cleared from outside.
320
+ const storage = resolveStorage(storageOption, storageArea);
321
+ if (!storage || readRaw(storage) === null)
322
+ return true;
323
+ return read().deleted?.[threadId] !== undefined;
324
+ },
325
+ saveThread(chat) {
326
+ return withStorageLock(storageKey, () => transact((current) => mergeThread(current, chat), chat.threadId));
327
+ },
328
+ saveThreadSync(chat, staleIds = []) {
329
+ // No Web Lock: its callback is a task, and tasks do not run while the document unloads.
330
+ transact((current) => {
331
+ const merged = mergeThread(current, chat);
332
+ if (!merged)
333
+ return null;
334
+ return staleIds
335
+ .filter((id) => id !== chat.threadId)
336
+ .reduce((next, id) => retire(next, id), merged);
337
+ }, chat.threadId);
338
+ },
339
+ deleteThread(threadId) {
340
+ return withStorageLock(storageKey, () => transact((current) => retire(current, threadId)));
341
+ },
342
+ subscribe(listener) {
343
+ if (typeof window === 'undefined')
344
+ return () => { };
345
+ const onStorage = (event) => {
346
+ const area = resolveStorage(storageOption, storageArea);
347
+ if (area && event.storageArea && event.storageArea !== area)
348
+ return;
349
+ if (event.key === null || event.key === storageKey)
350
+ listener();
351
+ };
352
+ window.addEventListener('storage', onStorage);
353
+ return () => window.removeEventListener('storage', onStorage);
354
+ },
355
+ };
356
+ }
357
+ const sharedStores = new Map();
358
+ /**
359
+ * One store per namespace and area for the page, so a chat mounted again finds the answers still
360
+ * streaming into it. Not shared on the server, where the map would outlive the request.
361
+ */
362
+ export function sharedLocalStoragePersistence(namespace, storageArea) {
363
+ if (typeof window === 'undefined')
364
+ return createLocalStoragePersistence({ namespace, storageArea });
365
+ const id = `${storageArea}:${namespace}`;
366
+ const existing = sharedStores.get(id);
367
+ if (existing)
368
+ return existing;
369
+ const store = createLocalStoragePersistence({ namespace, storageArea });
370
+ sharedStores.set(id, store);
371
+ return store;
372
+ }
373
+ export default createLocalStoragePersistence;
@@ -19,6 +19,7 @@ const defaultTranslations = {
19
19
  'CioAsa.results.saleBadge': 'Sale',
20
20
  'CioAsa.refinement.ariaLabel': 'Refine your results',
21
21
  'CioAsa.error.message': "I can't assist you with that request.",
22
+ 'CioAsa.error.interrupted': 'The answer was interrupted. Please send your question again.',
22
23
  };
23
24
  export default function translate(key, translations) {
24
25
  if (translations?.[key] !== undefined) {
@@ -1 +1 @@
1
- export default '1.7.2';
1
+ export default '1.8.0';
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { ChatComponentOverrides, ResultGroupMeta, Translations } from '../../types';
2
+ import { ChatComponentOverrides, ResultGroupMeta, ThreadSummary, Translations } from '../../types';
3
3
  import { Product, NormalizeOptions } from '../../utils/productNormalizer';
4
4
  import { AspectRatio } from '../ResultsBlock/ResultsBlock';
5
5
  export interface ChatHandle {
@@ -9,8 +9,12 @@ export interface ChatHandle {
9
9
  * No-op when nothing is streaming.
10
10
  */
11
11
  abort: () => void;
12
- /** Cancel any active response, then reset the conversation and the thread. */
12
+ /** Cancel any active response, then reset the conversation and delete it from storage. */
13
13
  clearHistory: () => void;
14
+ /** Start an empty conversation, keeping the current one in storage. */
15
+ newThread: () => void;
16
+ /** Load a stored conversation. No-op when persistence is off. */
17
+ switchThread: (threadId: string) => Promise<void>;
14
18
  }
15
19
  export interface ChatProps {
16
20
  /** Called when the close button (✕) is clicked. The consumer controls visibility. */
@@ -42,6 +46,8 @@ export interface ChatProps {
42
46
  translations?: Translations;
43
47
  /** Seed the thread id (e.g. loaded from browser storage) to resume a prior conversation. Read once on mount. */
44
48
  initialThreadId?: string;
49
+ /** Fires with the stored conversations and the active one whenever either changes. Requires persistence. */
50
+ onThreadsChange?: (threads: ThreadSummary[], activeThreadId: string | null) => void;
45
51
  /**
46
52
  * Whether the input's send button becomes a stop button while a reply streams.
47
53
  * Defaults to `false`, so the packaged UI is unchanged unless you opt in. While it is
@@ -10,5 +10,5 @@ export type CioAsaProps = CioAsaProviderProps & ChatProps;
10
10
  * directly. This is the single-entry-point convenience wrapper, and it is what the
11
11
  * standalone browser bundle (`src/bundled.jsx`) mounts.
12
12
  */
13
- declare const CioAsa: React.ForwardRefExoticComponent<CioAsaProviderProps & ChatProps & React.RefAttributes<ChatHandle>>;
13
+ declare const CioAsa: React.ForwardRefExoticComponent<CioAsaProps & React.RefAttributes<ChatHandle>>;
14
14
  export default CioAsa;