@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
@@ -1,11 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const tslib_1 = require("tslib");
4
- const react_1 = tslib_1.__importDefault(require("react"));
4
+ const react_1 = tslib_1.__importStar(require("react"));
5
5
  const constructorio_ui_components_1 = require("@constructor-io/constructorio-ui-components");
6
6
  const icons_1 = require("../icons");
7
- function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', }) {
8
- return (react_1.default.createElement(constructorio_ui_components_1.Button, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: onClick },
7
+ const useCioAsaContext_1 = require("../../hooks/useCioAsaContext");
8
+ const useAsaTracking_1 = tslib_1.__importDefault(require("../../hooks/useAsaTracking"));
9
+ const AGENT_BUTTON_CLICK_MODE = 'chat';
10
+ function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', positionOnPage, pageType, instanceId, }) {
11
+ var _a, _b, _c;
12
+ const context = (0, useCioAsaContext_1.useCioAsaContext)();
13
+ const agentDomain = (_a = context === null || context === void 0 ? void 0 : context.staticRequestConfigs) === null || _a === void 0 ? void 0 : _a.domain;
14
+ const tracking = (0, useAsaTracking_1.default)({
15
+ tracker: (_c = (_b = context === null || context === void 0 ? void 0 : context.cioClient) === null || _b === void 0 ? void 0 : _b.tracker) !== null && _c !== void 0 ? _c : undefined,
16
+ section: context === null || context === void 0 ? void 0 : context.section,
17
+ });
18
+ const handleClick = (0, react_1.useCallback)(() => {
19
+ var _a, _b;
20
+ if (agentDomain) {
21
+ const payload = Object.assign(Object.assign(Object.assign({ mode: AGENT_BUTTON_CLICK_MODE, agentDomain }, (positionOnPage && { positionOnPage })), (pageType && { pageType })), (instanceId && { instanceId }));
22
+ tracking.trackAgentButtonClick(payload);
23
+ (_b = (_a = context === null || context === void 0 ? void 0 : context.callbacks) === null || _a === void 0 ? void 0 : _a.onAgentButtonClick) === null || _b === void 0 ? void 0 : _b.call(_a, payload);
24
+ }
25
+ onClick === null || onClick === void 0 ? void 0 : onClick();
26
+ }, [agentDomain, positionOnPage, pageType, instanceId, tracking, context, onClick]);
27
+ return (react_1.default.createElement(constructorio_ui_components_1.Button, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: handleClick },
9
28
  theme === 'dark' ? react_1.default.createElement(icons_1.ChatBubbleDarkIcon, null) : react_1.default.createElement(icons_1.ChatBubbleLightIcon, null),
10
29
  react_1.default.createElement("span", { className: 'cio-asa-button__label' }, label)));
11
30
  }
@@ -23,6 +23,9 @@
23
23
  }
24
24
 
25
25
  .cio-asa-ai-message__text {
26
+ width: 100%;
27
+ min-width: 0;
28
+ overflow-wrap: break-word;
26
29
  font-weight: 400;
27
30
  font-size: 14px;
28
31
  line-height: 20px;
@@ -33,3 +36,52 @@
33
36
  .cio-asa-ai-message__bubble--error .cio-asa-ai-message__text {
34
37
  color: #B42318;
35
38
  }
39
+
40
+ /* Agent markdown (see utils/markdown.ts) */
41
+ .cio-asa-ai-message__text > :first-child {
42
+ margin-top: 0;
43
+ }
44
+
45
+ .cio-asa-ai-message__text > :last-child {
46
+ margin-bottom: 0;
47
+ }
48
+
49
+ .cio-asa-ai-message__text p {
50
+ margin: 0 0 8px;
51
+ }
52
+
53
+ .cio-asa-ai-message__text ul,
54
+ .cio-asa-ai-message__text ol {
55
+ margin: 0 0 8px;
56
+ padding-left: 20px;
57
+ }
58
+
59
+ .cio-asa-ai-message__text a {
60
+ color: inherit;
61
+ text-decoration: underline;
62
+ }
63
+
64
+ /* Wide tables scroll inside the bubble instead of widening it. */
65
+ .cio-asa-ai-message__text table {
66
+ display: block;
67
+ max-width: 100%;
68
+ overflow-x: auto;
69
+ margin: 0 0 8px;
70
+ border-collapse: collapse;
71
+ font-size: 12px;
72
+ line-height: 16px;
73
+ }
74
+
75
+ .cio-asa-ai-message__text th,
76
+ .cio-asa-ai-message__text td {
77
+ padding: 6px 8px;
78
+ border: 1px solid #D5D9E2;
79
+ background: #FFFFFF;
80
+ text-align: left;
81
+ vertical-align: top;
82
+ }
83
+
84
+ .cio-asa-ai-message__text th {
85
+ background: #F6F7F9;
86
+ font-weight: 600;
87
+ }
@@ -4,6 +4,7 @@ const tslib_1 = require("tslib");
4
4
  const react_1 = tslib_1.__importDefault(require("react"));
5
5
  const constructorio_ui_components_1 = require("@constructor-io/constructorio-ui-components");
6
6
  const translate_1 = tslib_1.__importDefault(require("../../utils/translate"));
7
+ const markdown_1 = require("../../utils/markdown");
7
8
  const TypingIndicator_1 = tslib_1.__importDefault(require("./TypingIndicator"));
8
9
  function AiMessage({ message, componentOverrides, translations }) {
9
10
  var _a, _b, _c;
@@ -15,6 +16,7 @@ function AiMessage({ message, componentOverrides, translations }) {
15
16
  const errorKey = message.interrupted ? 'CioAsa.error.interrupted' : 'CioAsa.error.message';
16
17
  const text = isError ? message.text || (0, translate_1.default)(errorKey, translations) : message.text || '';
17
18
  const hasText = text.length > 0;
19
+ const html = (0, markdown_1.renderMarkdown)(text);
18
20
  const loaderRenderProps = { translations };
19
21
  const textRenderProps = { text };
20
22
  return (react_1.default.createElement("div", { className: 'cio-asa-ai-message' },
@@ -26,8 +28,10 @@ function AiMessage({ message, componentOverrides, translations }) {
26
28
  isError && 'cio-asa-ai-message__bubble--error',
27
29
  ]
28
30
  .filter(Boolean)
29
- .join(' '), role: isError ? 'alert' : undefined },
30
- react_1.default.createElement("div", { className: 'cio-asa-ai-message__text' }, text))))));
31
+ .join(' '), role: isError ? 'alert' : undefined }, html ? (react_1.default.createElement("div", { className: 'cio-asa-ai-message__text',
32
+ // Sanitized by renderMarkdown
33
+ // eslint-disable-next-line react/no-danger
34
+ dangerouslySetInnerHTML: { __html: html } })) : (react_1.default.createElement("div", { className: 'cio-asa-ai-message__text' }, text)))))));
31
35
  }
32
36
  exports.default = AiMessage;
33
37
  AiMessage.displayName = 'AiMessage';
@@ -8,6 +8,7 @@ const useFocusTrap_1 = tslib_1.__importDefault(require("../../hooks/useFocusTrap
8
8
  const useLatest_1 = tslib_1.__importDefault(require("../../hooks/useLatest"));
9
9
  const productNormalizer_1 = require("../../utils/productNormalizer");
10
10
  const translate_1 = tslib_1.__importDefault(require("../../utils/translate"));
11
+ const markdown_1 = require("../../utils/markdown");
11
12
  const ChatHeader_1 = tslib_1.__importDefault(require("./ChatHeader"));
12
13
  const WelcomeScreen_1 = tslib_1.__importDefault(require("./WelcomeScreen"));
13
14
  const ChatMessageList_1 = tslib_1.__importDefault(require("./ChatMessageList"));
@@ -25,11 +26,11 @@ function getAnnouncement(messages, translations) {
25
26
  return `${youSaid}: ${userText}. ${typing}`;
26
27
  }
27
28
  if (lastMessage.status === 'done' && lastMessage.text) {
28
- return `${(0, translate_1.default)('CioAsa.aiMessage.ariaLabel', translations)}: ${lastMessage.text}`;
29
+ return `${(0, translate_1.default)('CioAsa.aiMessage.ariaLabel', translations)}: ${(0, markdown_1.markdownToText)(lastMessage.text)}`;
29
30
  }
30
31
  return '';
31
32
  }
32
- const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, onThreadsChange, showStopButton = false, }, ref) => {
33
+ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, initialPrompt, onThreadsChange, showStopButton = false, }, ref) => {
33
34
  var _a;
34
35
  const { messages, sendMessage, isStreaming, canAbort, abort, clearHistory, isHydrating, threads, activeThreadId, newThread, switchThread, } = (0, useAsaResults_1.default)({
35
36
  initialThreadId,
@@ -49,12 +50,25 @@ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions,
49
50
  const showChatFrame = showChat || (isHydrating && lastViewRef.current === 'chat');
50
51
  const isModal = typeof onClose === 'function';
51
52
  const announcement = getAnnouncement(messages, translations);
52
- (0, react_1.useImperativeHandle)(ref, () => ({ abort, clearHistory, newThread, switchThread }), [
53
+ (0, react_1.useImperativeHandle)(ref, () => ({
53
54
  abort,
54
55
  clearHistory,
55
56
  newThread,
56
57
  switchThread,
57
- ]);
58
+ sendMessage: (text, options) => sendMessage(text, 'external', options),
59
+ }), [abort, clearHistory, newThread, switchThread, sendMessage]);
60
+ // Sent from a timer so StrictMode's mount, unmount, mount cannot cancel the stream it starts.
61
+ const initialPromptRef = (0, react_1.useRef)(initialPrompt);
62
+ (0, react_1.useEffect)(() => {
63
+ const prompt = initialPromptRef.current;
64
+ if (isHydrating || !prompt)
65
+ return undefined;
66
+ const timer = setTimeout(() => {
67
+ initialPromptRef.current = undefined;
68
+ sendMessage(prompt, 'external');
69
+ });
70
+ return () => clearTimeout(timer);
71
+ }, [isHydrating, sendMessage]);
58
72
  const hasPersistence = Boolean((_a = (0, useCioAsaContext_1.useCioAsaContext)()) === null || _a === void 0 ? void 0 : _a.persistence);
59
73
  const onThreadsChangeRef = (0, useLatest_1.default)(onThreadsChange);
60
74
  (0, react_1.useEffect)(() => {
@@ -12,8 +12,7 @@ function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort,
12
12
  isDisabled,
13
13
  submitOnEnterOnly: true,
14
14
  });
15
- // The stop button only makes sense when there is something to stop and a way to do it.
16
- const canAbort = isStreaming && showStopButton && typeof onAbort === 'function';
15
+ const canAbort = isStreaming && typeof onAbort === 'function';
17
16
  const renderProps = {
18
17
  value,
19
18
  onChange: setValue,
@@ -21,13 +20,14 @@ function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort,
21
20
  placeholder: (0, translate_1.default)('CioAsa.input.placeholder', translations),
22
21
  isDisabled,
23
22
  isStreaming,
23
+ canAbort,
24
24
  onAbort: onAbort !== null && onAbort !== void 0 ? onAbort : (() => { }),
25
25
  };
26
26
  return (react_1.default.createElement(constructorio_ui_components_1.RenderPropsWrapper, { override: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.reactNode, props: renderProps },
27
27
  react_1.default.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
28
28
  react_1.default.createElement("div", { className: 'cio-asa-chat-input' },
29
29
  react_1.default.createElement("input", { type: 'text', className: 'cio-asa-chat-input__field', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: renderProps.placeholder, disabled: isDisabled, "aria-label": (0, translate_1.default)('CioAsa.input.ariaLabel', translations) }),
30
- canAbort ? (react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": (0, translate_1.default)('CioAsa.input.stopAriaLabel', translations) },
30
+ canAbort && showStopButton ? (react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": (0, translate_1.default)('CioAsa.input.stopAriaLabel', translations) },
31
31
  react_1.default.createElement(icons_1.StopIcon, null))) : (react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": (0, translate_1.default)('CioAsa.input.sendAriaLabel', translations) },
32
32
  react_1.default.createElement(icons_1.SendArrowIcon, null)))))));
33
33
  }
@@ -36,7 +36,7 @@ function handleSearchResult(data, pendingGroup, assistantId, setMessages) {
36
36
  }
37
37
  exports.handleSearchResult = handleSearchResult;
38
38
  function handleMessage(data, assistantId, setMessages) {
39
- updateMessageById(setMessages, assistantId, (msg) => (Object.assign(Object.assign({}, msg), { status: 'streaming', text: (msg.text || '') + ((data === null || data === void 0 ? void 0 : data.text) || '') })));
39
+ updateMessageById(setMessages, assistantId, (msg) => (Object.assign(Object.assign({}, msg), { status: 'streaming', text: [msg.text, data === null || data === void 0 ? void 0 : data.text].filter(Boolean).join('\n') })));
40
40
  }
41
41
  exports.handleMessage = handleMessage;
42
42
  function handleFollowUpRefinement(data, assistantId, setMessages) {
@@ -31,6 +31,9 @@ class BackgroundTurn {
31
31
  this.save().then(notify);
32
32
  },
33
33
  onDone: () => {
34
+ if (!this.streaming)
35
+ return;
36
+ this.release();
34
37
  this.streaming = false;
35
38
  this.messages = (0, chatThreads_1.normalizeHydratedMessages)(this.messages);
36
39
  this.save().then(() => {
@@ -61,6 +64,36 @@ class BackgroundTurn {
61
64
  /** Later writes go into `store`. */
62
65
  moveTo(store) {
63
66
  this.store = store;
67
+ if (this.streaming)
68
+ this.listen();
69
+ }
70
+ /** Stops the answer when another tab asks to, through the store it is saved into. */
71
+ listen() {
72
+ var _a, _b;
73
+ this.release();
74
+ this.stopListening = (_b = (_a = this.store).subscribeAbort) === null || _b === void 0 ? void 0 : _b.call(_a, ({ messageId }) => {
75
+ if (messageId === this.turn.assistantId)
76
+ this.abort();
77
+ });
78
+ }
79
+ release() {
80
+ var _a;
81
+ (_a = this.stopListening) === null || _a === void 0 ? void 0 : _a.call(this);
82
+ this.stopListening = undefined;
83
+ }
84
+ /** Stops the answer and stores it settled, as the stop button does on screen. */
85
+ abort() {
86
+ if (!this.streaming)
87
+ return;
88
+ this.streaming = false;
89
+ this.release();
90
+ this.turn.cancel();
91
+ this.messages = (0, chatThreads_1.settleCancelledReply)(this.messages, this.turn.assistantId);
92
+ this.save().then(() => {
93
+ if (this.saved)
94
+ turns.delete(this);
95
+ notify();
96
+ });
64
97
  }
65
98
  /** Stores the conversation settled, without awaiting: the page is going away. */
66
99
  flush() {
@@ -82,6 +115,7 @@ let listeningForPagehide = false;
82
115
  function continueInBackground(turn, params) {
83
116
  const bg = new BackgroundTurn(turn, params);
84
117
  turns.add(bg);
118
+ bg.listen();
85
119
  if (!listeningForPagehide && typeof window !== 'undefined') {
86
120
  listeningForPagehide = true;
87
121
  window.addEventListener('pagehide', () => turns.forEach((t) => t.flush()));
@@ -98,6 +132,7 @@ function resumeBackgroundTurn(store, threadId, createOwner) {
98
132
  if (!bg)
99
133
  return null;
100
134
  turns.delete(bg);
135
+ bg.release();
101
136
  if (bg.streaming)
102
137
  bg.turn.owner = createOwner(bg.turn);
103
138
  return bg;
@@ -10,10 +10,12 @@ const useWriteQueue_1 = tslib_1.__importDefault(require("./useWriteQueue"));
10
10
  const useThreadList_1 = tslib_1.__importDefault(require("./useThreadList"));
11
11
  const useForeignStream_1 = tslib_1.__importDefault(require("./useForeignStream"));
12
12
  const backgroundTurns_1 = require("./backgroundTurns");
13
+ /** Whether `store` can both ask another tab to stop an answer and hear such a request. */
14
+ const canAbortAcrossTabs = (store) => Boolean((store === null || store === void 0 ? void 0 : store.requestAbort) && store.subscribeAbort);
13
15
  const hasUnsavedWork = (session) => session.isStreaming || session.dirty || session.orphanIds.length > 0;
14
16
  /** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
15
17
  function useChatPersistence(params) {
16
- const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, } = params;
18
+ const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, abortTurn, } = params;
17
19
  if (store)
18
20
  (0, chatThreads_1.getTabId)();
19
21
  const [isHydrating, setIsHydrating] = (0, react_1.useState)(Boolean(store));
@@ -61,6 +63,15 @@ function useChatPersistence(params) {
61
63
  setIsHydrating(Boolean(store));
62
64
  }
63
65
  }
66
+ /** Settles the answer another tab left in flight, and writes it back. */
67
+ const settleForeign = (0, react_1.useCallback)((threadId) => {
68
+ if (!mountedRef.current || session.storageThreadId !== threadId)
69
+ return;
70
+ if (session.isStreaming)
71
+ return;
72
+ session.dirty = true;
73
+ setMessages((prev) => (0, chatThreads_1.normalizeHydratedMessages)(prev));
74
+ }, [session, setMessages, mountedRef]);
64
75
  /** Puts a stored conversation on screen and makes it the one this tab continues. */
65
76
  const showStoredChat = (0, react_1.useCallback)((chat) => {
66
77
  (0, chatSession_1.adoptStoredChat)(session, chat);
@@ -75,15 +86,18 @@ function useChatPersistence(params) {
75
86
  return;
76
87
  }
77
88
  setMessages(chat.messages);
78
- watchForeign(remainingMs, () => {
79
- if (!mountedRef.current || session.storageThreadId !== chat.threadId)
80
- return;
81
- if (session.isStreaming)
82
- return;
83
- session.dirty = true;
84
- setMessages((prev) => (0, chatThreads_1.normalizeHydratedMessages)(prev));
85
- });
86
- }, [session, stopForeign, watchForeign, setMessages, mountedRef]);
89
+ watchForeign(remainingMs, () => settleForeign(chat.threadId));
90
+ }, [session, stopForeign, watchForeign, settleForeign, setMessages]);
91
+ /** Asks the tab streaming the answer on screen to stop it; settles it here if no tab does. */
92
+ const abortForeign = (0, react_1.useCallback)(() => {
93
+ const { current } = storeRef;
94
+ const threadId = session.storageThreadId;
95
+ const last = messagesRef.current[messagesRef.current.length - 1];
96
+ if (!canAbortAcrossTabs(current) || !session.foreignInFlight || !threadId || !last)
97
+ return;
98
+ current.requestAbort(threadId, last.id);
99
+ watchForeign(chatThreads_1.FOREIGN_ABORT_TIMEOUT_MS, () => settleForeign(threadId));
100
+ }, [session, storeRef, messagesRef, watchForeign, settleForeign]);
87
101
  /** Writes the conversation as it is now; `settle` finishes an in-flight answer, `sync` skips awaiting. */
88
102
  const persistNow = (0, react_1.useCallback)((mode) => {
89
103
  const { current } = storeRef;
@@ -327,6 +341,15 @@ function useChatPersistence(params) {
327
341
  settleAndPersist();
328
342
  }, [detachTurn, settleAndPersist]);
329
343
  (0, react_1.useEffect)(() => (0, backgroundTurns_1.subscribeBackgroundTurns)(refreshThreads), [refreshThreads]);
344
+ (0, react_1.useEffect)(() => {
345
+ if (!(store === null || store === void 0 ? void 0 : store.subscribeAbort))
346
+ return undefined;
347
+ return store.subscribeAbort(({ messageId }) => {
348
+ var _a;
349
+ if (((_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.assistantId) === messageId)
350
+ abortTurn();
351
+ });
352
+ }, [store, turnRef, abortTurn]);
330
353
  // Follow changes another tab made to the list or to the thread on screen.
331
354
  const syncFromStore = (0, react_1.useCallback)(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
332
355
  var _a;
@@ -475,6 +498,8 @@ function useChatPersistence(params) {
475
498
  threads,
476
499
  activeThreadId,
477
500
  foreignInFlight,
501
+ canAbortForeign: foreignInFlight && canAbortAcrossTabs(store),
502
+ abortForeign,
478
503
  beginTurn,
479
504
  screenOwner,
480
505
  clearHistory,
@@ -8,6 +8,7 @@ const useChatPersistence_1 = tslib_1.__importDefault(require("./persistence/useC
8
8
  const agentStream_1 = require("./agentStream");
9
9
  const chatSession_1 = require("../utils/chatSession");
10
10
  const productNormalizer_1 = require("../utils/productNormalizer");
11
+ const chatThreads_1 = require("../utils/chatThreads");
11
12
  const useLatest_1 = tslib_1.__importDefault(require("./useLatest"));
12
13
  function useAsaResults(options) {
13
14
  var _a;
@@ -43,6 +44,19 @@ function useAsaResults(options) {
43
44
  (_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.cancel();
44
45
  turnRef.current = null;
45
46
  }, []);
47
+ const abortOwnTurn = (0, react_1.useCallback)(() => {
48
+ var _a;
49
+ if (!session.isStreaming)
50
+ return;
51
+ const abortedId = (_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.assistantId;
52
+ cancelStream();
53
+ session.isStreaming = false;
54
+ setIsStreaming(false);
55
+ if (abortedId)
56
+ setMessages((prev) => (0, chatThreads_1.settleCancelledReply)(prev, abortedId));
57
+ // With persistence on, the cancelled turn is stored as it stands.
58
+ session.dirty = true;
59
+ }, [session, cancelStream]);
46
60
  const store = (0, useChatPersistence_1.default)({
47
61
  store: persistence,
48
62
  scope: persistenceScope,
@@ -55,16 +69,17 @@ function useAsaResults(options) {
55
69
  setIsStreaming,
56
70
  turnRef,
57
71
  cancelStream,
72
+ abortTurn: abortOwnTurn,
58
73
  });
59
- const { beginTurn, screenOwner } = store;
74
+ const { beginTurn, screenOwner, abortForeign } = store;
60
75
  (0, react_1.useEffect)(() => cancelStream, [cancelStream]);
61
- const sendMessage = (0, react_1.useCallback)((text, source = 'input') => {
76
+ const sendToCurrentThread = (0, react_1.useCallback)((text, source) => {
62
77
  var _a, _b;
63
78
  const intent = text.trim();
64
79
  if (!intent || session.isStreaming || session.foreignInFlight)
65
80
  return;
66
81
  beginTurn();
67
- trackingRef.current.trackSubmit(intent);
82
+ trackingRef.current.trackSubmit(intent, source);
68
83
  (_b = (_a = callbacksRef.current) === null || _a === void 0 ? void 0 : _a.onAssistantSubmit) === null || _b === void 0 ? void 0 : _b.call(_a, { intent, source });
69
84
  const userMessage = {
70
85
  id: (0, chatSession_1.nextMessageId)(session),
@@ -113,6 +128,32 @@ function useAsaResults(options) {
113
128
  callbacksRef,
114
129
  staticRequestConfigsRef,
115
130
  ]);
131
+ const { newThread, switchThread } = store;
132
+ /**
133
+ * Sends `text`. With `threadId`, first moves to that conversation, loading it from storage
134
+ * when persistence has it and otherwise continuing it on the server with an empty screen.
135
+ */
136
+ const sendMessage = (0, react_1.useCallback)((text, source = 'input', { threadId } = {}) => {
137
+ if (!threadId ||
138
+ threadId === session.serverThreadId ||
139
+ threadId === session.storageThreadId) {
140
+ sendToCurrentThread(text, source);
141
+ return;
142
+ }
143
+ (() => tslib_1.__awaiter(this, void 0, void 0, function* () {
144
+ if (persistence)
145
+ yield switchThread(threadId);
146
+ else
147
+ newThread();
148
+ // A turn that started meanwhile (or one still streaming in the background) owns the screen.
149
+ if (session.isStreaming || session.foreignInFlight)
150
+ return;
151
+ if (!session.serverThreadId && !(0, chatThreads_1.isLocalThreadId)(threadId)) {
152
+ session.serverThreadId = threadId;
153
+ }
154
+ sendToCurrentThread(text, source);
155
+ }))();
156
+ }, [session, persistence, newThread, switchThread, sendToCurrentThread]);
116
157
  /**
117
158
  * Cancels the in-flight request while keeping the conversation: the partial reply is
118
159
  * settled as `done` (so the typing indicator stops and streamed text survives) and the
@@ -127,40 +168,27 @@ function useAsaResults(options) {
127
168
  * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
128
169
  * finished would be false. An aborted turn therefore leaves an
129
170
  * `assistant_result_load_started` with no matching finished event.
171
+ *
172
+ * An answer streaming in another tab is stopped by that tab, which stores the result.
130
173
  */
131
174
  const abort = (0, react_1.useCallback)(() => {
132
- var _a;
133
- if (!session.isStreaming)
134
- return;
135
- const abortedId = (_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.assistantId;
136
- cancelStream();
137
- session.isStreaming = false;
138
- setIsStreaming(false);
139
- if (abortedId) {
140
- setMessages((prev) => prev.flatMap((msg) => {
141
- var _a;
142
- if (msg.id !== abortedId)
143
- return [msg];
144
- if (!msg.text && !((_a = msg.groups) === null || _a === void 0 ? void 0 : _a.length) && !msg.refinement)
145
- return [];
146
- return [Object.assign(Object.assign({}, msg), { status: 'done' })];
147
- }));
148
- }
149
- // With persistence on, the cancelled turn is stored as it stands.
150
- session.dirty = true;
151
- }, [session, cancelStream]);
175
+ if (session.isStreaming)
176
+ abortOwnTurn();
177
+ else
178
+ abortForeign();
179
+ }, [session, abortOwnTurn, abortForeign]);
152
180
  return {
153
181
  messages,
154
182
  sendMessage,
155
183
  isStreaming: isStreaming || store.foreignInFlight,
156
- canAbort: isStreaming,
184
+ canAbort: isStreaming || store.canAbortForeign,
157
185
  abort,
158
186
  clearHistory: store.clearHistory,
159
187
  isHydrating: store.isHydrating,
160
188
  threads: store.threads,
161
189
  activeThreadId: store.activeThreadId,
162
- newThread: store.newThread,
163
- switchThread: store.switchThread,
190
+ newThread,
191
+ switchThread,
164
192
  };
165
193
  }
166
194
  exports.default = useAsaResults;
@@ -1,7 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const react_1 = require("react");
4
+ /** Beacon `source` values, aligned with the ones the behavioral-actions API documents. */
5
+ const BEACON_SUBMIT_SOURCES = {
6
+ input: 'input',
7
+ suggestion: 'suggestion',
8
+ refinement: 'follow_up',
9
+ // A prompt the host page sent, typically a suggested question clicked outside the chat.
10
+ external: 'suggestion',
11
+ };
4
12
  const NOOP_TRACKING = {
13
+ trackAgentButtonClick: () => { },
5
14
  trackSubmit: () => { },
6
15
  trackResultLoadStarted: () => { },
7
16
  trackResultLoadFinished: () => { },
@@ -15,37 +24,42 @@ const NOOP_TRACKING = {
15
24
  * Mirrors the tracking-hook pattern used by constructorio-ui-pia.
16
25
  */
17
26
  function useAsaTracking({ tracker, section, threadId, }) {
18
- const assistant = tracker;
19
27
  const base = (0, react_1.useMemo)(() => (Object.assign(Object.assign({}, (section && { section })), (threadId && { threadId }))), [section, threadId]);
20
- const trackSubmit = (0, react_1.useCallback)((intent) => {
21
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantSubmit(Object.assign({ intent }, base));
22
- }, [assistant, base]);
28
+ const trackAgentButtonClick = (0, react_1.useCallback)(({ mode, agentDomain, positionOnPage, pageType, instanceId }) => {
29
+ var _a;
30
+ (_a = tracker === null || tracker === void 0 ? void 0 : tracker.trackAgentButtonClick) === null || _a === void 0 ? void 0 : _a.call(tracker, Object.assign(Object.assign(Object.assign(Object.assign({ mode,
31
+ agentDomain }, (positionOnPage && { positionOnPage })), (pageType && { pageType })), (instanceId && { instanceId })), (section && { section })));
32
+ }, [tracker, section]);
33
+ const trackSubmit = (0, react_1.useCallback)((intent, source) => {
34
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantSubmit(Object.assign(Object.assign({ intent }, (source && { source: BEACON_SUBMIT_SOURCES[source] })), base));
35
+ }, [tracker, base]);
23
36
  const trackResultLoadStarted = (0, react_1.useCallback)(({ intent, intentResultId }) => {
24
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantResultLoadStarted(Object.assign(Object.assign({ intent }, (intentResultId && { intentResultId })), base));
25
- }, [assistant, base]);
37
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantResultLoadStarted(Object.assign(Object.assign({ intent }, (intentResultId && { intentResultId })), base));
38
+ }, [tracker, base]);
26
39
  const trackResultLoadFinished = (0, react_1.useCallback)(({ intent, searchResultCount, intentResultId, threadId: turnThreadId, }) => {
27
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantResultLoadFinished(Object.assign(Object.assign(Object.assign({ intent,
40
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantResultLoadFinished(Object.assign(Object.assign(Object.assign({ intent,
28
41
  searchResultCount }, (intentResultId && { intentResultId })), base), (turnThreadId && { threadId: turnThreadId })));
29
- }, [assistant, base]);
42
+ }, [tracker, base]);
30
43
  const trackResultClick = (0, react_1.useCallback)(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
31
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantResultClick(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ intent,
44
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantResultClick(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ intent,
32
45
  searchResultId }, (intentResultId && { intentResultId })), (itemId && { itemId })), (itemName && { itemName })), (variationId && { variationId })), base));
33
- }, [assistant, base]);
46
+ }, [tracker, base]);
34
47
  const trackResultView = (0, react_1.useCallback)(({ intent, searchResultId, numResultsViewed, intentResultId, items }) => {
35
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantResultView(Object.assign(Object.assign(Object.assign({ intent,
48
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantResultView(Object.assign(Object.assign(Object.assign({ intent,
36
49
  searchResultId,
37
50
  numResultsViewed }, (intentResultId && { intentResultId })), (items && items.length > 0 && { items })), base));
38
- }, [assistant, base]);
51
+ }, [tracker, base]);
39
52
  const trackSearchSubmit = (0, react_1.useCallback)(({ intent, searchTerm, userInput, searchResultId, intentResultId, groupId, }) => {
40
- assistant === null || assistant === void 0 ? void 0 : assistant.trackAssistantSearchSubmit(Object.assign(Object.assign(Object.assign({ intent,
53
+ tracker === null || tracker === void 0 ? void 0 : tracker.trackAssistantSearchSubmit(Object.assign(Object.assign(Object.assign({ intent,
41
54
  searchTerm,
42
55
  userInput,
43
56
  searchResultId }, (intentResultId && { intentResultId })), (groupId && { groupId })), base));
44
- }, [assistant, base]);
57
+ }, [tracker, base]);
45
58
  return (0, react_1.useMemo)(() => {
46
- if (!assistant)
59
+ if (!tracker)
47
60
  return NOOP_TRACKING;
48
61
  return {
62
+ trackAgentButtonClick,
49
63
  trackSubmit,
50
64
  trackResultLoadStarted,
51
65
  trackResultLoadFinished,
@@ -54,7 +68,8 @@ function useAsaTracking({ tracker, section, threadId, }) {
54
68
  trackSearchSubmit,
55
69
  };
56
70
  }, [
57
- assistant,
71
+ tracker,
72
+ trackAgentButtonClick,
58
73
  trackSubmit,
59
74
  trackResultLoadStarted,
60
75
  trackResultLoadFinished,
package/lib/cjs/index.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ConstructorIOClient = exports.clearPersistedConversations = exports.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = exports.CioAsa = void 0;
6
+ exports.ConstructorIOClient = exports.renderMarkdown = exports.clearPersistedConversations = exports.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = exports.CioAsa = void 0;
7
7
  // Components
8
8
  var CioAsa_1 = require("./components/CioAsa/CioAsa");
9
9
  Object.defineProperty(exports, "CioAsa", { enumerable: true, get: function () { return __importDefault(CioAsa_1).default; } });
@@ -27,6 +27,8 @@ var productNormalizer_1 = require("./utils/productNormalizer");
27
27
  Object.defineProperty(exports, "normalizeItemToProduct", { enumerable: true, get: function () { return productNormalizer_1.normalizeItemToProduct; } });
28
28
  var localStoragePersistence_1 = require("./utils/localStoragePersistence");
29
29
  Object.defineProperty(exports, "clearPersistedConversations", { enumerable: true, get: function () { return localStoragePersistence_1.clearPersistedConversations; } });
30
+ var markdown_1 = require("./utils/markdown");
31
+ Object.defineProperty(exports, "renderMarkdown", { enumerable: true, get: function () { return markdown_1.renderMarkdown; } });
30
32
  // Re-exported client value from the JS client (used to construct/type a cioClient)
31
33
  var types_1 = require("./types");
32
34
  Object.defineProperty(exports, "ConstructorIOClient", { enumerable: true, get: function () { return types_1.ConstructorIOClient; } });
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.moveThreads = exports.foreignStreamRemainingMs = exports.isThreadStreaming = exports.findRekeyedThread = exports.saveThreadAndRetireStale = exports.mergeMessages = exports.compactMessages = exports.normalizeHydratedMessages = exports.isInFlight = exports.nextMessageCounter = exports.getThreadTitle = exports.isLocalThreadId = exports.getTabId = exports.createLocalThreadId = exports.randomId = exports.IN_FLIGHT_GRACE_MS = exports.DEFAULT_PERSISTENCE_TTL_MS = exports.DEFAULT_PERSISTENCE_KEY = exports.PERSISTED_CHAT_VERSION = void 0;
3
+ exports.moveThreads = exports.foreignStreamRemainingMs = exports.isThreadStreaming = exports.findRekeyedThread = exports.saveThreadAndRetireStale = exports.mergeMessages = exports.compactMessages = exports.settleCancelledReply = exports.normalizeHydratedMessages = exports.isInFlight = exports.nextMessageCounter = exports.getThreadTitle = exports.isLocalThreadId = exports.getTabId = exports.createLocalThreadId = exports.randomId = exports.FOREIGN_ABORT_TIMEOUT_MS = exports.IN_FLIGHT_GRACE_MS = exports.DEFAULT_PERSISTENCE_TTL_MS = exports.DEFAULT_PERSISTENCE_KEY = exports.PERSISTED_CHAT_VERSION = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  /** Stored record format; records with another version are ignored on read. */
6
6
  exports.PERSISTED_CHAT_VERSION = 1;
@@ -10,6 +10,8 @@ exports.DEFAULT_PERSISTENCE_KEY = 'cio-asa:chat';
10
10
  exports.DEFAULT_PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
11
11
  /** How long a stored answer written by another tab is still shown as "typing" before it is settled. */
12
12
  exports.IN_FLIGHT_GRACE_MS = 60 * 1000;
13
+ /** How long to wait for the tab streaming an answer to store it stopped, before settling it here. */
14
+ exports.FOREIGN_ABORT_TIMEOUT_MS = 3 * 1000;
13
15
  /** Marks client-side thread ids, used before the server has assigned one. Never sent to the agent. */
14
16
  const LOCAL_THREAD_PREFIX = 'local-';
15
17
  /** Thread titles are the first question, cut to this many characters. */
@@ -125,6 +127,18 @@ function normalizeHydratedMessages(messages) {
125
127
  });
126
128
  }
127
129
  exports.normalizeHydratedMessages = normalizeHydratedMessages;
130
+ /** `messages` with the cancelled reply settled: `done` when it has content, dropped otherwise. */
131
+ function settleCancelledReply(messages, assistantId) {
132
+ return messages.flatMap((m) => {
133
+ var _a;
134
+ if (m.id !== assistantId)
135
+ return [m];
136
+ if (!m.text && !((_a = m.groups) === null || _a === void 0 ? void 0 : _a.length) && !m.refinement)
137
+ return [];
138
+ return [Object.assign(Object.assign({}, m), { status: 'done' })];
139
+ });
140
+ }
141
+ exports.settleCancelledReply = settleCancelledReply;
128
142
  /** Messages as stored: raw items replaced by the cards `normalize` makes; a restored group keeps its cards. */
129
143
  function compactMessages(messages, normalize) {
130
144
  return messages.map((m) => {