@constructor-io/constructorio-ui-asa 1.9.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 (36) hide show
  1. package/README.md +8 -210
  2. package/dist/constructorio-ui-asa-bundled.js +78 -20
  3. package/lib/cjs/components/Chat/AiMessage.css +52 -0
  4. package/lib/cjs/components/Chat/AiMessage.js +6 -2
  5. package/lib/cjs/components/Chat/Chat.js +2 -1
  6. package/lib/cjs/components/Chat/ChatInput.js +3 -3
  7. package/lib/cjs/hooks/asaStreamHandlers.js +1 -1
  8. package/lib/cjs/hooks/persistence/backgroundTurns.js +35 -0
  9. package/lib/cjs/hooks/persistence/useChatPersistence.js +35 -10
  10. package/lib/cjs/hooks/useAsaResults.js +23 -22
  11. package/lib/cjs/index.js +3 -1
  12. package/lib/cjs/utils/chatThreads.js +15 -1
  13. package/lib/cjs/utils/localStoragePersistence.js +110 -19
  14. package/lib/cjs/utils/markdown.js +60 -0
  15. package/lib/cjs/version.js +1 -1
  16. package/lib/mjs/components/Chat/AiMessage.css +52 -0
  17. package/lib/mjs/components/Chat/AiMessage.js +6 -2
  18. package/lib/mjs/components/Chat/Chat.js +2 -1
  19. package/lib/mjs/components/Chat/ChatInput.js +3 -3
  20. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  21. package/lib/mjs/hooks/persistence/backgroundTurns.js +35 -1
  22. package/lib/mjs/hooks/persistence/useChatPersistence.js +35 -11
  23. package/lib/mjs/hooks/useAsaResults.js +23 -21
  24. package/lib/mjs/index.js +1 -0
  25. package/lib/mjs/utils/chatThreads.js +12 -0
  26. package/lib/mjs/utils/localStoragePersistence.js +106 -14
  27. package/lib/mjs/utils/markdown.js +53 -0
  28. package/lib/mjs/version.js +1 -1
  29. package/lib/types/hooks/persistence/backgroundTurns.d.ts +6 -0
  30. package/lib/types/hooks/persistence/useChatPersistence.d.ts +4 -0
  31. package/lib/types/index.d.ts +1 -0
  32. package/lib/types/types.d.ts +21 -5
  33. package/lib/types/utils/chatThreads.d.ts +4 -0
  34. package/lib/types/utils/markdown.d.ts +8 -0
  35. package/lib/types/version.d.ts +1 -1
  36. package/package.json +5 -1
@@ -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.9.0';
1
+ export default '1.10.0';
@@ -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;
@@ -13,6 +13,7 @@ export { default as useCioAsaContext } from './hooks/useCioAsaContext';
13
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
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';
@@ -193,7 +193,10 @@ export interface UseChatReturn {
193
193
  sendMessage: (text: string, source?: AssistantSubmitSource, options?: SendMessageOptions) => void;
194
194
  /** True while an answer is streaming, here or, with persistence on, in another tab on the same thread. */
195
195
  isStreaming: boolean;
196
- /** True while this tab's own answer is streaming, the one `abort` can stop. */
196
+ /**
197
+ * True while `abort` can stop the answer: this tab's own, or, when the store can reach it, one
198
+ * streaming in another tab.
199
+ */
197
200
  canAbort: boolean;
198
201
  /**
199
202
  * Cancel the in-flight request, keeping the conversation. The partial reply is settled
@@ -270,6 +273,18 @@ export interface ChatPersistence {
270
273
  * Returns an unsubscribe function.
271
274
  */
272
275
  subscribe?(listener: () => void): () => void;
276
+ /** Optional. Ask the tab streaming `messageId` into `threadId` to stop it. */
277
+ requestAbort?(threadId: string, messageId: string): void;
278
+ /**
279
+ * Optional. Notify when another tab asks to stop an answer, see `requestAbort`. Returns an
280
+ * unsubscribe function. Without both, an answer streaming in another tab cannot be stopped.
281
+ */
282
+ subscribeAbort?(listener: (request: AbortRequest) => void): () => void;
283
+ }
284
+ /** Another tab asks to stop the answer `messageId` streaming into `threadId`. */
285
+ export interface AbortRequest {
286
+ threadId: string;
287
+ messageId: string;
273
288
  }
274
289
  /** Which stored history `clearPersistedConversations` deletes; mirror what the provider was given. */
275
290
  export interface ClearPersistedConversationsOptions {
@@ -416,12 +431,13 @@ export interface ChatInputRenderProps {
416
431
  onSubmit: () => void;
417
432
  placeholder: string;
418
433
  isDisabled: boolean;
434
+ /** Whether a reply is currently streaming, here or in another tab; for status and disabled state. */
435
+ isStreaming: boolean;
419
436
  /**
420
- * Whether a reply is currently streaming. Pair it with `onAbort` to offer a cancel
421
- * control while it is true — the built-in stop button is off by default, so an override
422
- * is often the only way a user can cancel.
437
+ * Whether `onAbort` can stop the reply streaming now; show a cancel control only then. The
438
+ * built-in stop button is off by default, so an override is often the only way a user can cancel.
423
439
  */
424
- isStreaming: boolean;
440
+ canAbort: boolean;
425
441
  /** Cancel the in-flight reply. Keeps the conversation and the thread. */
426
442
  onAbort: () => void;
427
443
  }
@@ -8,6 +8,8 @@ export declare const DEFAULT_PERSISTENCE_KEY = "cio-asa:chat";
8
8
  export declare const DEFAULT_PERSISTENCE_TTL_MS: number;
9
9
  /** How long a stored answer written by another tab is still shown as "typing" before it is settled. */
10
10
  export declare const IN_FLIGHT_GRACE_MS: number;
11
+ /** How long to wait for the tab streaming an answer to store it stopped, before settling it here. */
12
+ export declare const FOREIGN_ABORT_TIMEOUT_MS: number;
11
13
  /** Random token for the ids that must not collide between tabs: threads, tabs and messages. */
12
14
  export declare function randomId(): string;
13
15
  /** Client-side thread id for a conversation the server has not named yet. */
@@ -28,6 +30,8 @@ export declare function nextMessageCounter(messages: ChatMessage[]): number;
28
30
  export declare function isInFlight(messages: ChatMessage[]): boolean;
29
31
  /** Settles answers stored mid-stream: `done` when they have content, an interrupted `error` otherwise. */
30
32
  export declare function normalizeHydratedMessages(messages: ChatMessage[]): ChatMessage[];
33
+ /** `messages` with the cancelled reply settled: `done` when it has content, dropped otherwise. */
34
+ export declare function settleCancelledReply(messages: ChatMessage[], assistantId: string): ChatMessage[];
31
35
  /** Makes the product card for a raw search-result item. */
32
36
  export type ItemNormalizer = (item: Record<string, unknown>) => Product;
33
37
  /** Messages as stored: raw items replaced by the cards `normalize` makes; a restored group keeps its cards. */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Turns the agent's markdown into sanitized HTML: paragraphs, bold, links, lists and tables.
3
+ * Links to other sites become plain text. Browser only: without a DOM to sanitize with (server
4
+ * rendering) it returns `''`, so render the plain text instead.
5
+ */
6
+ export declare function renderMarkdown(markdown: string): string;
7
+ /** The agent's markdown as plain text, one space between blocks and table cells, for screen readers. */
8
+ export declare function markdownToText(markdown: string): string;
@@ -1,2 +1,2 @@
1
- declare const _default: "1.9.0";
1
+ declare const _default: "1.10.0";
2
2
  export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructor-io/constructorio-ui-asa",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Constructor.io AI Shopping Agent UI library for web applications",
5
5
  "author": "constructor.io",
6
6
  "license": "MIT",
@@ -148,5 +148,9 @@
148
148
  "volta": {
149
149
  "node": "22.18.0",
150
150
  "npm": "10.9.3"
151
+ },
152
+ "dependencies": {
153
+ "dompurify": "3.4.17",
154
+ "marked": "15.0.12"
151
155
  }
152
156
  }