@constructor-io/constructorio-ui-asa 1.8.0 → 1.10.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 (43) hide show
  1. package/README.md +8 -210
  2. package/dist/constructorio-ui-asa-bundled.js +79 -21
  3. package/lib/cjs/components/Button/Button.js +22 -3
  4. package/lib/cjs/components/Chat/AiMessage.css +52 -0
  5. package/lib/cjs/components/Chat/AiMessage.js +6 -2
  6. package/lib/cjs/components/Chat/Chat.js +18 -4
  7. package/lib/cjs/components/Chat/ChatInput.js +3 -3
  8. package/lib/cjs/hooks/asaStreamHandlers.js +1 -1
  9. package/lib/cjs/hooks/persistence/backgroundTurns.js +35 -0
  10. package/lib/cjs/hooks/persistence/useChatPersistence.js +35 -10
  11. package/lib/cjs/hooks/useAsaResults.js +54 -26
  12. package/lib/cjs/hooks/useAsaTracking.js +31 -16
  13. package/lib/cjs/index.js +3 -1
  14. package/lib/cjs/utils/chatThreads.js +15 -1
  15. package/lib/cjs/utils/localStoragePersistence.js +110 -19
  16. package/lib/cjs/utils/markdown.js +60 -0
  17. package/lib/cjs/version.js +1 -1
  18. package/lib/mjs/components/Button/Button.js +26 -3
  19. package/lib/mjs/components/Chat/AiMessage.css +52 -0
  20. package/lib/mjs/components/Chat/AiMessage.js +6 -2
  21. package/lib/mjs/components/Chat/Chat.js +18 -4
  22. package/lib/mjs/components/Chat/ChatInput.js +3 -3
  23. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  24. package/lib/mjs/hooks/persistence/backgroundTurns.js +35 -1
  25. package/lib/mjs/hooks/persistence/useChatPersistence.js +35 -11
  26. package/lib/mjs/hooks/useAsaResults.js +53 -24
  27. package/lib/mjs/hooks/useAsaTracking.js +40 -16
  28. package/lib/mjs/index.js +1 -0
  29. package/lib/mjs/utils/chatThreads.js +12 -0
  30. package/lib/mjs/utils/localStoragePersistence.js +106 -14
  31. package/lib/mjs/utils/markdown.js +53 -0
  32. package/lib/mjs/version.js +1 -1
  33. package/lib/types/components/Button/Button.d.ts +3 -2
  34. package/lib/types/components/Chat/Chat.d.ts +13 -1
  35. package/lib/types/hooks/persistence/backgroundTurns.d.ts +6 -0
  36. package/lib/types/hooks/persistence/useChatPersistence.d.ts +4 -0
  37. package/lib/types/hooks/useAsaTracking.d.ts +4 -2
  38. package/lib/types/index.d.ts +3 -2
  39. package/lib/types/types.d.ts +57 -8
  40. package/lib/types/utils/chatThreads.d.ts +4 -0
  41. package/lib/types/utils/markdown.d.ts +8 -0
  42. package/lib/types/version.d.ts +1 -1
  43. package/package.json +6 -2
@@ -5,6 +5,7 @@ import useChatPersistence from './persistence/useChatPersistence';
5
5
  import { startTurn } from './agentStream';
6
6
  import { createChatSession, nextMessageId } from '../utils/chatSession';
7
7
  import { normalizeItemToProduct } from '../utils/productNormalizer';
8
+ import { isLocalThreadId, settleCancelledReply } from '../utils/chatThreads';
8
9
  import useLatest from './useLatest';
9
10
  export default function useAsaResults(options) {
10
11
  const contextValue = useCioAsaContext();
@@ -38,6 +39,18 @@ export default function useAsaResults(options) {
38
39
  turnRef.current?.cancel();
39
40
  turnRef.current = null;
40
41
  }, []);
42
+ const abortOwnTurn = useCallback(() => {
43
+ if (!session.isStreaming)
44
+ return;
45
+ const abortedId = turnRef.current?.assistantId;
46
+ cancelStream();
47
+ session.isStreaming = false;
48
+ setIsStreaming(false);
49
+ if (abortedId)
50
+ setMessages((prev) => settleCancelledReply(prev, abortedId));
51
+ // With persistence on, the cancelled turn is stored as it stands.
52
+ session.dirty = true;
53
+ }, [session, cancelStream]);
41
54
  const store = useChatPersistence({
42
55
  store: persistence,
43
56
  scope: persistenceScope,
@@ -50,15 +63,16 @@ export default function useAsaResults(options) {
50
63
  setIsStreaming,
51
64
  turnRef,
52
65
  cancelStream,
66
+ abortTurn: abortOwnTurn,
53
67
  });
54
- const { beginTurn, screenOwner } = store;
68
+ const { beginTurn, screenOwner, abortForeign } = store;
55
69
  useEffect(() => cancelStream, [cancelStream]);
56
- const sendMessage = useCallback((text, source = 'input') => {
70
+ const sendToCurrentThread = useCallback((text, source) => {
57
71
  const intent = text.trim();
58
72
  if (!intent || session.isStreaming || session.foreignInFlight)
59
73
  return;
60
74
  beginTurn();
61
- trackingRef.current.trackSubmit(intent);
75
+ trackingRef.current.trackSubmit(intent, source);
62
76
  callbacksRef.current?.onAssistantSubmit?.({ intent, source });
63
77
  const userMessage = {
64
78
  id: nextMessageId(session),
@@ -108,6 +122,32 @@ export default function useAsaResults(options) {
108
122
  callbacksRef,
109
123
  staticRequestConfigsRef,
110
124
  ]);
125
+ const { newThread, switchThread } = store;
126
+ /**
127
+ * Sends `text`. With `threadId`, first moves to that conversation, loading it from storage
128
+ * when persistence has it and otherwise continuing it on the server with an empty screen.
129
+ */
130
+ const sendMessage = useCallback((text, source = 'input', { threadId } = {}) => {
131
+ if (!threadId ||
132
+ threadId === session.serverThreadId ||
133
+ threadId === session.storageThreadId) {
134
+ sendToCurrentThread(text, source);
135
+ return;
136
+ }
137
+ (async () => {
138
+ if (persistence)
139
+ await switchThread(threadId);
140
+ else
141
+ newThread();
142
+ // A turn that started meanwhile (or one still streaming in the background) owns the screen.
143
+ if (session.isStreaming || session.foreignInFlight)
144
+ return;
145
+ if (!session.serverThreadId && !isLocalThreadId(threadId)) {
146
+ session.serverThreadId = threadId;
147
+ }
148
+ sendToCurrentThread(text, source);
149
+ })();
150
+ }, [session, persistence, newThread, switchThread, sendToCurrentThread]);
111
151
  /**
112
152
  * Cancels the in-flight request while keeping the conversation: the partial reply is
113
153
  * settled as `done` (so the typing indicator stops and streamed text survives) and the
@@ -122,37 +162,26 @@ export default function useAsaResults(options) {
122
162
  * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
123
163
  * finished would be false. An aborted turn therefore leaves an
124
164
  * `assistant_result_load_started` with no matching finished event.
165
+ *
166
+ * An answer streaming in another tab is stopped by that tab, which stores the result.
125
167
  */
126
168
  const abort = useCallback(() => {
127
- if (!session.isStreaming)
128
- return;
129
- const abortedId = turnRef.current?.assistantId;
130
- cancelStream();
131
- session.isStreaming = false;
132
- setIsStreaming(false);
133
- if (abortedId) {
134
- setMessages((prev) => prev.flatMap((msg) => {
135
- if (msg.id !== abortedId)
136
- return [msg];
137
- if (!msg.text && !msg.groups?.length && !msg.refinement)
138
- return [];
139
- return [{ ...msg, status: 'done' }];
140
- }));
141
- }
142
- // With persistence on, the cancelled turn is stored as it stands.
143
- session.dirty = true;
144
- }, [session, cancelStream]);
169
+ if (session.isStreaming)
170
+ abortOwnTurn();
171
+ else
172
+ abortForeign();
173
+ }, [session, abortOwnTurn, abortForeign]);
145
174
  return {
146
175
  messages,
147
176
  sendMessage,
148
177
  isStreaming: isStreaming || store.foreignInFlight,
149
- canAbort: isStreaming,
178
+ canAbort: isStreaming || store.canAbortForeign,
150
179
  abort,
151
180
  clearHistory: store.clearHistory,
152
181
  isHydrating: store.isHydrating,
153
182
  threads: store.threads,
154
183
  activeThreadId: store.activeThreadId,
155
- newThread: store.newThread,
156
- switchThread: store.switchThread,
184
+ newThread,
185
+ switchThread,
157
186
  };
158
187
  }
@@ -1,5 +1,14 @@
1
1
  import { useCallback, useMemo } from 'react';
2
+ /** Beacon `source` values, aligned with the ones the behavioral-actions API documents. */
3
+ const BEACON_SUBMIT_SOURCES = {
4
+ input: 'input',
5
+ suggestion: 'suggestion',
6
+ refinement: 'follow_up',
7
+ // A prompt the host page sent, typically a suggested question clicked outside the chat.
8
+ external: 'suggestion',
9
+ };
2
10
  const NOOP_TRACKING = {
11
+ trackAgentButtonClick: () => { },
3
12
  trackSubmit: () => { },
4
13
  trackResultLoadStarted: () => { },
5
14
  trackResultLoadFinished: () => { },
@@ -13,32 +22,45 @@ const NOOP_TRACKING = {
13
22
  * Mirrors the tracking-hook pattern used by constructorio-ui-pia.
14
23
  */
15
24
  export default function useAsaTracking({ tracker, section, threadId, }) {
16
- const assistant = tracker;
17
25
  const base = useMemo(() => ({
18
26
  ...(section && { section }),
19
27
  ...(threadId && { threadId }),
20
28
  }), [section, threadId]);
21
- const trackSubmit = useCallback((intent) => {
22
- assistant?.trackAssistantSubmit({ intent, ...base });
23
- }, [assistant, base]);
29
+ const trackAgentButtonClick = useCallback(({ mode, agentDomain, positionOnPage, pageType, instanceId }) => {
30
+ tracker?.trackAgentButtonClick?.({
31
+ mode,
32
+ agentDomain,
33
+ ...(positionOnPage && { positionOnPage }),
34
+ ...(pageType && { pageType }),
35
+ ...(instanceId && { instanceId }),
36
+ ...(section && { section }),
37
+ });
38
+ }, [tracker, section]);
39
+ const trackSubmit = useCallback((intent, source) => {
40
+ tracker?.trackAssistantSubmit({
41
+ intent,
42
+ ...(source && { source: BEACON_SUBMIT_SOURCES[source] }),
43
+ ...base,
44
+ });
45
+ }, [tracker, base]);
24
46
  const trackResultLoadStarted = useCallback(({ intent, intentResultId }) => {
25
- assistant?.trackAssistantResultLoadStarted({
47
+ tracker?.trackAssistantResultLoadStarted({
26
48
  intent,
27
49
  ...(intentResultId && { intentResultId }),
28
50
  ...base,
29
51
  });
30
- }, [assistant, base]);
52
+ }, [tracker, base]);
31
53
  const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId, threadId: turnThreadId, }) => {
32
- assistant?.trackAssistantResultLoadFinished({
54
+ tracker?.trackAssistantResultLoadFinished({
33
55
  intent,
34
56
  searchResultCount,
35
57
  ...(intentResultId && { intentResultId }),
36
58
  ...base,
37
59
  ...(turnThreadId && { threadId: turnThreadId }),
38
60
  });
39
- }, [assistant, base]);
61
+ }, [tracker, base]);
40
62
  const trackResultClick = useCallback(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
41
- assistant?.trackAssistantResultClick({
63
+ tracker?.trackAssistantResultClick({
42
64
  intent,
43
65
  searchResultId,
44
66
  ...(intentResultId && { intentResultId }),
@@ -47,9 +69,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
47
69
  ...(variationId && { variationId }),
48
70
  ...base,
49
71
  });
50
- }, [assistant, base]);
72
+ }, [tracker, base]);
51
73
  const trackResultView = useCallback(({ intent, searchResultId, numResultsViewed, intentResultId, items }) => {
52
- assistant?.trackAssistantResultView({
74
+ tracker?.trackAssistantResultView({
53
75
  intent,
54
76
  searchResultId,
55
77
  numResultsViewed,
@@ -57,9 +79,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
57
79
  ...(items && items.length > 0 && { items }),
58
80
  ...base,
59
81
  });
60
- }, [assistant, base]);
82
+ }, [tracker, base]);
61
83
  const trackSearchSubmit = useCallback(({ intent, searchTerm, userInput, searchResultId, intentResultId, groupId, }) => {
62
- assistant?.trackAssistantSearchSubmit({
84
+ tracker?.trackAssistantSearchSubmit({
63
85
  intent,
64
86
  searchTerm,
65
87
  userInput,
@@ -68,11 +90,12 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
68
90
  ...(groupId && { groupId }),
69
91
  ...base,
70
92
  });
71
- }, [assistant, base]);
93
+ }, [tracker, base]);
72
94
  return useMemo(() => {
73
- if (!assistant)
95
+ if (!tracker)
74
96
  return NOOP_TRACKING;
75
97
  return {
98
+ trackAgentButtonClick,
76
99
  trackSubmit,
77
100
  trackResultLoadStarted,
78
101
  trackResultLoadFinished,
@@ -81,7 +104,8 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
81
104
  trackSearchSubmit,
82
105
  };
83
106
  }, [
84
- assistant,
107
+ tracker,
108
+ trackAgentButtonClick,
85
109
  trackSubmit,
86
110
  trackResultLoadStarted,
87
111
  trackResultLoadFinished,
package/lib/mjs/index.js CHANGED
@@ -11,5 +11,6 @@ export { default as useCioAsaContext } from './hooks/useCioAsaContext';
11
11
  // Utils
12
12
  export { normalizeItemToProduct } from './utils/productNormalizer';
13
13
  export { clearPersistedConversations } from './utils/localStoragePersistence';
14
+ export { renderMarkdown } from './utils/markdown';
14
15
  // Re-exported client value from the JS client (used to construct/type a cioClient)
15
16
  export { ConstructorIOClient } from './types';
@@ -6,6 +6,8 @@ export const DEFAULT_PERSISTENCE_KEY = 'cio-asa:chat';
6
6
  export const DEFAULT_PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7
7
  /** How long a stored answer written by another tab is still shown as "typing" before it is settled. */
8
8
  export const IN_FLIGHT_GRACE_MS = 60 * 1000;
9
+ /** How long to wait for the tab streaming an answer to store it stopped, before settling it here. */
10
+ export const FOREIGN_ABORT_TIMEOUT_MS = 3 * 1000;
9
11
  /** Marks client-side thread ids, used before the server has assigned one. Never sent to the agent. */
10
12
  const LOCAL_THREAD_PREFIX = 'local-';
11
13
  /** Thread titles are the first question, cut to this many characters. */
@@ -111,6 +113,16 @@ export function normalizeHydratedMessages(messages) {
111
113
  return hasContent ? { ...m, status: 'done' } : { ...m, status: 'error', interrupted: true };
112
114
  });
113
115
  }
116
+ /** `messages` with the cancelled reply settled: `done` when it has content, dropped otherwise. */
117
+ export function settleCancelledReply(messages, assistantId) {
118
+ return messages.flatMap((m) => {
119
+ if (m.id !== assistantId)
120
+ return [m];
121
+ if (!m.text && !m.groups?.length && !m.refinement)
122
+ return [];
123
+ return [{ ...m, status: 'done' }];
124
+ });
125
+ }
114
126
  /** Messages as stored: raw items replaced by the cards `normalize` makes; a restored group keeps its cards. */
115
127
  export function compactMessages(messages, normalize) {
116
128
  return messages.map((m) => m.groups?.length
@@ -64,21 +64,31 @@ const emptyStored = () => ({
64
64
  threads: {},
65
65
  deleted: {},
66
66
  });
67
- function parseStored(raw) {
68
- if (!raw)
69
- return emptyStored();
67
+ /** The record `raw` holds, or `null` when it is not one of this version. */
68
+ function readStored(raw) {
70
69
  try {
71
70
  const parsed = JSON.parse(raw);
72
71
  if (parsed?.version !== PERSISTED_CHAT_VERSION || !parsed.threads)
73
- return emptyStored();
72
+ return null;
74
73
  const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => isPersistedChat(chat)));
75
74
  const deleted = Object.fromEntries(Object.entries(parsed.deleted ?? {}).filter(([, at]) => typeof at === 'number'));
76
75
  return { version: PERSISTED_CHAT_VERSION, threads, deleted };
77
76
  }
78
77
  catch {
79
- return emptyStored();
78
+ return null;
80
79
  }
81
80
  }
81
+ function parseStored(raw) {
82
+ return (raw && readStored(raw)) || emptyStored();
83
+ }
84
+ function dropExpired(data, cutoff) {
85
+ return {
86
+ version: PERSISTED_CHAT_VERSION,
87
+ threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
88
+ deleted: Object.fromEntries(Object.entries(data.deleted ?? {}).filter(([, at]) => at >= cutoff)),
89
+ };
90
+ }
91
+ const entryCount = (data) => Object.keys(data.threads).length + Object.keys(data.deleted ?? {}).length;
82
92
  function readItem(storage, key) {
83
93
  try {
84
94
  return storage.getItem(key);
@@ -87,6 +97,42 @@ function readItem(storage, key) {
87
97
  return null;
88
98
  }
89
99
  }
100
+ /** How often a write is redone when another tab changed the key between its read and its write. */
101
+ const MAX_COMMIT_ATTEMPTS = 3;
102
+ /** Drops expired threads and tombstones under every namespace of `key`, other shoppers' included. */
103
+ async function sweepExpired(storage, key, ttlMs) {
104
+ const prefix = storageKeyFor(key);
105
+ let keys;
106
+ try {
107
+ keys = Array.from({ length: storage.length }, (_, i) => storage.key(i)).filter((k) => k === prefix || Boolean(k?.startsWith(`${prefix}:`)));
108
+ }
109
+ catch {
110
+ return;
111
+ }
112
+ await Promise.all(keys.map((k) => withStorageLock(k, () => {
113
+ for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
114
+ const raw = readItem(storage, k);
115
+ const stored = raw === null ? null : readStored(raw);
116
+ if (!stored)
117
+ return;
118
+ const kept = dropExpired(stored, Date.now() - ttlMs);
119
+ if (entryCount(kept) === entryCount(stored))
120
+ return;
121
+ if (readItem(storage, k) === raw) {
122
+ try {
123
+ if (entryCount(kept) === 0)
124
+ storage.removeItem(k);
125
+ else
126
+ storage.setItem(k, JSON.stringify(kept));
127
+ }
128
+ catch {
129
+ /* storage unavailable */
130
+ }
131
+ return;
132
+ }
133
+ }
134
+ })));
135
+ }
90
136
  /**
91
137
  * Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
92
138
  * follow. Resolves once the deletion is recorded.
@@ -132,6 +178,23 @@ export async function clearPersistedConversations(options) {
132
178
  /* a non-Storage `storage` option cannot be attached to the event */
133
179
  }
134
180
  }
181
+ /** The channel abort requests travel on between tabs, or `null` where none can be opened. */
182
+ function openAbortChannel(name, onRequest) {
183
+ if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined')
184
+ return null;
185
+ try {
186
+ const channel = new BroadcastChannel(name);
187
+ channel.onmessage = ({ data }) => {
188
+ if (typeof data?.threadId !== 'string' || typeof data?.messageId !== 'string')
189
+ return;
190
+ onRequest({ threadId: data.threadId, messageId: data.messageId });
191
+ };
192
+ return channel;
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ }
135
198
  /** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
136
199
  export function createLocalStoragePersistence(options = {}) {
137
200
  const { key = DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
@@ -144,15 +207,20 @@ export function createLocalStoragePersistence(options = {}) {
144
207
  return emptyStored();
145
208
  if (lastParsed?.raw !== raw)
146
209
  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
- };
210
+ return dropExpired(lastParsed.data, Date.now() - ttlMs);
211
+ };
212
+ // Other shoppers' records expire only here: their own store may never run again on this device.
213
+ let swept = false;
214
+ const sweepOnce = () => {
215
+ if (swept)
216
+ return;
217
+ swept = true;
218
+ const local = resolveStorage(storageOption, 'local');
219
+ if (local)
220
+ sweepExpired(local, key, ttlMs).catch(() => { });
154
221
  };
155
222
  const read = () => {
223
+ sweepOnce();
156
224
  const storage = resolveStorage(storageOption, storageArea);
157
225
  return parse(storage ? readRaw(storage) : null);
158
226
  };
@@ -174,7 +242,7 @@ export function createLocalStoragePersistence(options = {}) {
174
242
  /* ignore */
175
243
  }
176
244
  };
177
- const isEmpty = (data) => Object.keys(data.threads).length === 0 && Object.keys(data.deleted ?? {}).length === 0;
245
+ const isEmpty = (data) => entryCount(data) === 0;
178
246
  // Retries without tombstones, oldest first: the last thing to shed, see `write`.
179
247
  const tryWriteShedding = (storage, data) => {
180
248
  if (tryWrite(storage, data))
@@ -238,8 +306,8 @@ export function createLocalStoragePersistence(options = {}) {
238
306
  tryWriteShedding(storage, others);
239
307
  };
240
308
  // Redo the merge if another tab wrote between our read and this write.
241
- const MAX_COMMIT_ATTEMPTS = 3;
242
309
  const transact = (mutate, priorityThreadId) => {
310
+ sweepOnce();
243
311
  const storage = resolveStorage(storageOption, storageArea);
244
312
  if (!storage)
245
313
  return;
@@ -297,6 +365,29 @@ export function createLocalStoragePersistence(options = {}) {
297
365
  }
298
366
  return next;
299
367
  };
368
+ const abortListeners = new Set();
369
+ // Only `localStorage` is shared between tabs; a guest's `sessionStorage` thread streams in this tab alone.
370
+ const channel = storageArea === 'local'
371
+ ? openAbortChannel(`${storageKey}:abort`, (request) => abortListeners.forEach((listener) => listener(request)))
372
+ : null;
373
+ const abortMethods = channel
374
+ ? {
375
+ requestAbort(threadId, messageId) {
376
+ try {
377
+ channel.postMessage({ threadId, messageId });
378
+ }
379
+ catch {
380
+ /* channel closed */
381
+ }
382
+ },
383
+ subscribeAbort(listener) {
384
+ abortListeners.add(listener);
385
+ return () => {
386
+ abortListeners.delete(listener);
387
+ };
388
+ },
389
+ }
390
+ : {};
300
391
  const retire = (data, staleId) => {
301
392
  const { [staleId]: retired, ...threads } = data.threads;
302
393
  return { ...data, threads, deleted: { ...data.deleted, [staleId]: Date.now() } };
@@ -352,6 +443,7 @@ export function createLocalStoragePersistence(options = {}) {
352
443
  window.addEventListener('storage', onStorage);
353
444
  return () => window.removeEventListener('storage', onStorage);
354
445
  },
446
+ ...abortMethods,
355
447
  };
356
448
  }
357
449
  const sharedStores = new Map();
@@ -0,0 +1,53 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { marked } from 'marked';
3
+ // The text comes from an LLM a shopper can steer, so only the tags `marked` outputs are kept.
4
+ // Images, media and SVG would load a URL with no click and could leak data; form controls and
5
+ // `style` could fake a form.
6
+ const purifyConfig = {
7
+ ALLOWED_TAGS: 'p br strong em del a ul ol li blockquote hr code pre table thead tbody tr th td h1 h2 h3 h4 h5 h6'.split(' '),
8
+ ALLOWED_ATTR: ['href', 'tabindex', 'align', 'start'],
9
+ };
10
+ // Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
11
+ // links to this site, its subdomains, email and phone stay clickable.
12
+ function isOnThisSite(href) {
13
+ try {
14
+ const url = new URL(href, window.location.href);
15
+ if (url.protocol === 'mailto:' || url.protocol === 'tel:')
16
+ return true;
17
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
18
+ return false;
19
+ const site = window.location.hostname.replace(/^www\./, '');
20
+ return url.hostname === site || url.hostname.endsWith(`.${site}`);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Turns the agent's markdown into sanitized HTML: paragraphs, bold, links, lists and tables.
28
+ * Links to other sites become plain text. Browser only: without a DOM to sanitize with (server
29
+ * rendering) it returns `''`, so render the plain text instead.
30
+ */
31
+ export function renderMarkdown(markdown) {
32
+ if (!markdown || !DOMPurify.isSupported)
33
+ return '';
34
+ const html = marked.parse(markdown, { async: false, breaks: true })
35
+ // A wide table scrolls (AiMessage.css), so keyboard users need to be able to focus it.
36
+ .replace(/<table>/g, '<table tabindex="0">');
37
+ const doc = new DOMParser().parseFromString(DOMPurify.sanitize(html, purifyConfig), 'text/html');
38
+ doc.querySelectorAll('a[href]').forEach((link) => {
39
+ if (!isOnThisSite(link.getAttribute('href')))
40
+ link.replaceWith(...Array.from(link.childNodes));
41
+ });
42
+ return doc.body.innerHTML;
43
+ }
44
+ /** The agent's markdown as plain text, one space between blocks and table cells, for screen readers. */
45
+ export function markdownToText(markdown) {
46
+ const html = renderMarkdown(markdown);
47
+ if (!html)
48
+ return markdown;
49
+ // An inert document: nothing in it loads or runs.
50
+ const { body } = new DOMParser().parseFromString(html, 'text/html');
51
+ body.querySelectorAll('p, li, th, td, br').forEach((node) => node.append(' '));
52
+ return (body.textContent ?? '').replace(/\s+/g, ' ').trim();
53
+ }
@@ -1 +1 @@
1
- export default '1.8.0';
1
+ export default '1.10.0';
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
- export interface ButtonProps {
2
+ import { AgentButtonClickPlacement } from '../../types';
3
+ export interface ButtonProps extends AgentButtonClickPlacement {
3
4
  /** Color scheme: dark (for light backgrounds) or light (for dark backgrounds) */
4
5
  theme?: 'dark' | 'light';
5
6
  /** Button size: sm (small) or lg (large) */
@@ -9,4 +10,4 @@ export interface ButtonProps {
9
10
  /** Button label text */
10
11
  label?: string;
11
12
  }
12
- export default function Button({ theme, size, onClick, label, }: ButtonProps): React.JSX.Element;
13
+ export default function Button({ theme, size, onClick, label, positionOnPage, pageType, instanceId, }: ButtonProps): React.JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { ChatComponentOverrides, ResultGroupMeta, ThreadSummary, Translations } from '../../types';
2
+ import { ChatComponentOverrides, ResultGroupMeta, SendMessageOptions, ThreadSummary, Translations } from '../../types';
3
3
  import { Product, NormalizeOptions } from '../../utils/productNormalizer';
4
4
  import { AspectRatio } from '../ResultsBlock/ResultsBlock';
5
5
  export interface ChatHandle {
@@ -15,6 +15,12 @@ export interface ChatHandle {
15
15
  newThread: () => void;
16
16
  /** Load a stored conversation. No-op when persistence is off. */
17
17
  switchThread: (threadId: string) => Promise<void>;
18
+ /**
19
+ * Send a prompt from outside the chat, e.g. one the shopper clicked elsewhere on the page.
20
+ * Pass `threadId` to send it into that conversation instead of the one on screen. Skipped
21
+ * while an answer is streaming. Opening the chat stays with you, as with `onClose`.
22
+ */
23
+ sendMessage: (text: string, options?: SendMessageOptions) => void;
18
24
  }
19
25
  export interface ChatProps {
20
26
  /** Called when the close button (✕) is clicked. The consumer controls visibility. */
@@ -46,6 +52,12 @@ export interface ChatProps {
46
52
  translations?: Translations;
47
53
  /** Seed the thread id (e.g. loaded from browser storage) to resume a prior conversation. Read once on mount. */
48
54
  initialThreadId?: string;
55
+ /**
56
+ * A prompt sent once on mount, after any stored conversation loads, e.g. one the shopper
57
+ * clicked elsewhere before you opened the chat. Goes into `initialThreadId` when set.
58
+ * Read once on mount; use `sendMessage` on the ref once the chat is already open.
59
+ */
60
+ initialPrompt?: string;
49
61
  /** Fires with the stored conversations and the active one whenever either changes. Requires persistence. */
50
62
  onThreadsChange?: (threads: ThreadSummary[], activeThreadId: string | null) => void;
51
63
  /**
@@ -15,6 +15,7 @@ export declare class BackgroundTurn {
15
15
  readonly ids: Set<string>;
16
16
  /** Its writes, in order; a chat that takes the turn back writes after them. */
17
17
  writes: Promise<void>;
18
+ private stopListening?;
18
19
  constructor(turn: LiveTurn, params: {
19
20
  store: ChatPersistence;
20
21
  session: ChatSession;
@@ -24,6 +25,11 @@ export declare class BackgroundTurn {
24
25
  save(): Promise<void>;
25
26
  /** Later writes go into `store`. */
26
27
  moveTo(store: ChatPersistence): void;
28
+ /** Stops the answer when another tab asks to, through the store it is saved into. */
29
+ listen(): void;
30
+ release(): void;
31
+ /** Stops the answer and stores it settled, as the stop button does on screen. */
32
+ abort(): void;
27
33
  /** Stores the conversation settled, without awaiting: the page is going away. */
28
34
  flush(): void;
29
35
  }
@@ -18,6 +18,8 @@ interface Params {
18
18
  turnRef: MutableRefObject<LiveTurn | null>;
19
19
  /** Stop the answer currently streaming in this tab, if any. */
20
20
  cancelStream: () => void;
21
+ /** Stop this tab's answer as the stop button does: settled and stored. */
22
+ abortTurn: () => void;
21
23
  }
22
24
  /** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
23
25
  export default function useChatPersistence(params: Params): {
@@ -25,6 +27,8 @@ export default function useChatPersistence(params: Params): {
25
27
  threads: import("../../types").ThreadSummary[];
26
28
  activeThreadId: string | null;
27
29
  foreignInFlight: boolean;
30
+ canAbortForeign: boolean;
31
+ abortForeign: () => void;
28
32
  beginTurn: () => void;
29
33
  screenOwner: (turn: LiveTurn) => TurnOwner;
30
34
  clearHistory: () => void;
@@ -1,5 +1,5 @@
1
1
  import { Tracker } from '@constructor-io/constructorio-client-javascript/lib/types/constructorio';
2
- import { AssistantTrackedItem } from '../types';
2
+ import { AgentButtonClickPayload, AssistantSubmitSource, AssistantTrackedItem } from '../types';
3
3
  export interface UseAsaTrackingProps {
4
4
  tracker?: Tracker;
5
5
  section?: string;
@@ -40,8 +40,10 @@ export interface TrackSearchSubmitArgs {
40
40
  intentResultId?: string;
41
41
  groupId?: string;
42
42
  }
43
+ export type TrackAgentButtonClickArgs = AgentButtonClickPayload;
43
44
  export interface UseAsaTrackingReturn {
44
- trackSubmit: (intent: string) => void;
45
+ trackAgentButtonClick: (args: TrackAgentButtonClickArgs) => void;
46
+ trackSubmit: (intent: string, source?: AssistantSubmitSource) => void;
45
47
  trackResultLoadStarted: (args: TrackResultLoadStartedArgs) => void;
46
48
  trackResultLoadFinished: (args: TrackResultLoadFinishedArgs) => void;
47
49
  trackResultClick: (args: TrackResultClickArgs) => void;
@@ -10,9 +10,10 @@ export type { ButtonProps } from './components/Button/Button';
10
10
  export { default as useAsaResults } from './hooks/useAsaResults';
11
11
  export { default as useAsaTracking } from './hooks/useAsaTracking';
12
12
  export { default as useCioAsaContext } from './hooks/useCioAsaContext';
13
- export type { UseAsaTrackingProps, UseAsaTrackingReturn } from './hooks/useAsaTracking';
13
+ export type { UseAsaTrackingProps, UseAsaTrackingReturn, TrackAgentButtonClickArgs, } from './hooks/useAsaTracking';
14
14
  export { normalizeItemToProduct } from './utils/productNormalizer';
15
15
  export { clearPersistedConversations } from './utils/localStoragePersistence';
16
+ export { renderMarkdown } from './utils/markdown';
16
17
  export type { Product, NormalizeOptions } from './utils/productNormalizer';
17
18
  export { ConstructorIOClient } from './types';
18
- export type { Nullable, CioAsaProviderProps, PersistConversationOptions, AsaContextValue, CioClientOptions, RequestConfigs, Formatters, UrlHelpers, QueryParamEncodingOptions, DefaultQueryStringMap, Translations, ChatMessage, ChatMessageStatus, FollowUpRefinement, ResultGroup, ResultGroupMeta, SearchResultEventRequest, UseAsaResultsOptions, UseChatReturn, ClearPersistedConversationsOptions, ThreadSummary, AsaCallbacks, AssistantSubmitSource, AssistantTrackedItem, ChatComponentOverrides, WelcomeScreenOverrides, ChatInputOverrides, AiMessageOverrides, ResultsBlockOverrides, ComponentOverrideProps, IncludeComponentOverrides, ChatHeaderRenderProps, ChatInputRenderProps, WelcomeScreenTitleRenderProps, WelcomeScreenInputRenderProps, SuggestedQuestionsRenderProps, UserMessageRenderProps, AiMessageLoaderRenderProps, AiMessageTextRenderProps, FollowUpRefinementRenderProps, ResultsGroupTitleRenderProps, ResultsViewMoreRenderProps, } from './types';
19
+ export type { Nullable, CioAsaProviderProps, PersistConversationOptions, AsaContextValue, CioClientOptions, RequestConfigs, Formatters, UrlHelpers, QueryParamEncodingOptions, DefaultQueryStringMap, Translations, ChatMessage, ChatMessageStatus, FollowUpRefinement, ResultGroup, ResultGroupMeta, SearchResultEventRequest, UseAsaResultsOptions, UseChatReturn, SendMessageOptions, ClearPersistedConversationsOptions, ThreadSummary, AsaCallbacks, AssistantSubmitSource, AssistantTrackedItem, AgentButtonClickMode, AgentButtonClickPayload, AgentButtonClickPageType, AgentButtonClickPlacement, ChatComponentOverrides, WelcomeScreenOverrides, ChatInputOverrides, AiMessageOverrides, ResultsBlockOverrides, ComponentOverrideProps, IncludeComponentOverrides, ChatHeaderRenderProps, ChatInputRenderProps, WelcomeScreenTitleRenderProps, WelcomeScreenInputRenderProps, SuggestedQuestionsRenderProps, UserMessageRenderProps, AiMessageLoaderRenderProps, AiMessageTextRenderProps, FollowUpRefinementRenderProps, ResultsGroupTitleRenderProps, ResultsViewMoreRenderProps, } from './types';