@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
@@ -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,7 +26,7 @@ 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
  }
@@ -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,
@@ -44,6 +44,19 @@ function useAsaResults(options) {
44
44
  (_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.cancel();
45
45
  turnRef.current = null;
46
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]);
47
60
  const store = (0, useChatPersistence_1.default)({
48
61
  store: persistence,
49
62
  scope: persistenceScope,
@@ -56,8 +69,9 @@ function useAsaResults(options) {
56
69
  setIsStreaming,
57
70
  turnRef,
58
71
  cancelStream,
72
+ abortTurn: abortOwnTurn,
59
73
  });
60
- const { beginTurn, screenOwner } = store;
74
+ const { beginTurn, screenOwner, abortForeign } = store;
61
75
  (0, react_1.useEffect)(() => cancelStream, [cancelStream]);
62
76
  const sendToCurrentThread = (0, react_1.useCallback)((text, source) => {
63
77
  var _a, _b;
@@ -154,33 +168,20 @@ function useAsaResults(options) {
154
168
  * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
155
169
  * finished would be false. An aborted turn therefore leaves an
156
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.
157
173
  */
158
174
  const abort = (0, react_1.useCallback)(() => {
159
- var _a;
160
- if (!session.isStreaming)
161
- return;
162
- const abortedId = (_a = turnRef.current) === null || _a === void 0 ? void 0 : _a.assistantId;
163
- cancelStream();
164
- session.isStreaming = false;
165
- setIsStreaming(false);
166
- if (abortedId) {
167
- setMessages((prev) => prev.flatMap((msg) => {
168
- var _a;
169
- if (msg.id !== abortedId)
170
- return [msg];
171
- if (!msg.text && !((_a = msg.groups) === null || _a === void 0 ? void 0 : _a.length) && !msg.refinement)
172
- return [];
173
- return [Object.assign(Object.assign({}, msg), { status: 'done' })];
174
- }));
175
- }
176
- // With persistence on, the cancelled turn is stored as it stands.
177
- session.dirty = true;
178
- }, [session, cancelStream]);
175
+ if (session.isStreaming)
176
+ abortOwnTurn();
177
+ else
178
+ abortForeign();
179
+ }, [session, abortOwnTurn, abortForeign]);
179
180
  return {
180
181
  messages,
181
182
  sendMessage,
182
183
  isStreaming: isStreaming || store.foreignInFlight,
183
- canAbort: isStreaming,
184
+ canAbort: isStreaming || store.canAbortForeign,
184
185
  abort,
185
186
  clearHistory: store.clearHistory,
186
187
  isHydrating: store.isHydrating,
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) => {
@@ -75,22 +75,33 @@ const emptyStored = () => ({
75
75
  threads: {},
76
76
  deleted: {},
77
77
  });
78
- function parseStored(raw) {
78
+ /** The record `raw` holds, or `null` when it is not one of this version. */
79
+ function readStored(raw) {
79
80
  var _a;
80
- if (!raw)
81
- return emptyStored();
82
81
  try {
83
82
  const parsed = JSON.parse(raw);
84
83
  if ((parsed === null || parsed === void 0 ? void 0 : parsed.version) !== chatThreads_1.PERSISTED_CHAT_VERSION || !parsed.threads)
85
- return emptyStored();
84
+ return null;
86
85
  const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => (0, chatRecordValidation_1.default)(chat)));
87
86
  const deleted = Object.fromEntries(Object.entries((_a = parsed.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => typeof at === 'number'));
88
87
  return { version: chatThreads_1.PERSISTED_CHAT_VERSION, threads, deleted };
89
88
  }
90
89
  catch (_b) {
91
- return emptyStored();
90
+ return null;
92
91
  }
93
92
  }
93
+ function parseStored(raw) {
94
+ return (raw && readStored(raw)) || emptyStored();
95
+ }
96
+ function dropExpired(data, cutoff) {
97
+ var _a;
98
+ return {
99
+ version: chatThreads_1.PERSISTED_CHAT_VERSION,
100
+ threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
101
+ deleted: Object.fromEntries(Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => at >= cutoff)),
102
+ };
103
+ }
104
+ const entryCount = (data) => { var _a; return Object.keys(data.threads).length + Object.keys((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).length; };
94
105
  function readItem(storage, key) {
95
106
  try {
96
107
  return storage.getItem(key);
@@ -99,6 +110,44 @@ function readItem(storage, key) {
99
110
  return null;
100
111
  }
101
112
  }
113
+ /** How often a write is redone when another tab changed the key between its read and its write. */
114
+ const MAX_COMMIT_ATTEMPTS = 3;
115
+ /** Drops expired threads and tombstones under every namespace of `key`, other shoppers' included. */
116
+ function sweepExpired(storage, key, ttlMs) {
117
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
118
+ const prefix = storageKeyFor(key);
119
+ let keys;
120
+ try {
121
+ keys = Array.from({ length: storage.length }, (_, i) => storage.key(i)).filter((k) => k === prefix || Boolean(k === null || k === void 0 ? void 0 : k.startsWith(`${prefix}:`)));
122
+ }
123
+ catch (_a) {
124
+ return;
125
+ }
126
+ yield Promise.all(keys.map((k) => withStorageLock(k, () => {
127
+ for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
128
+ const raw = readItem(storage, k);
129
+ const stored = raw === null ? null : readStored(raw);
130
+ if (!stored)
131
+ return;
132
+ const kept = dropExpired(stored, Date.now() - ttlMs);
133
+ if (entryCount(kept) === entryCount(stored))
134
+ return;
135
+ if (readItem(storage, k) === raw) {
136
+ try {
137
+ if (entryCount(kept) === 0)
138
+ storage.removeItem(k);
139
+ else
140
+ storage.setItem(k, JSON.stringify(kept));
141
+ }
142
+ catch (_a) {
143
+ /* storage unavailable */
144
+ }
145
+ return;
146
+ }
147
+ }
148
+ })));
149
+ });
150
+ }
102
151
  /**
103
152
  * Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
104
153
  * follow. Resolves once the deletion is recorded.
@@ -148,6 +197,23 @@ function clearPersistedConversations(options) {
148
197
  });
149
198
  }
150
199
  exports.clearPersistedConversations = clearPersistedConversations;
200
+ /** The channel abort requests travel on between tabs, or `null` where none can be opened. */
201
+ function openAbortChannel(name, onRequest) {
202
+ if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined')
203
+ return null;
204
+ try {
205
+ const channel = new BroadcastChannel(name);
206
+ channel.onmessage = ({ data }) => {
207
+ if (typeof (data === null || data === void 0 ? void 0 : data.threadId) !== 'string' || typeof (data === null || data === void 0 ? void 0 : data.messageId) !== 'string')
208
+ return;
209
+ onRequest({ threadId: data.threadId, messageId: data.messageId });
210
+ };
211
+ return channel;
212
+ }
213
+ catch (_a) {
214
+ return null;
215
+ }
216
+ }
151
217
  /** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
152
218
  function createLocalStoragePersistence(options = {}) {
153
219
  const { key = chatThreads_1.DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = chatThreads_1.DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
@@ -156,20 +222,24 @@ function createLocalStoragePersistence(options = {}) {
156
222
  // One sync reads the same key several times; parse and validate the blob once per distinct value.
157
223
  let lastParsed = null;
158
224
  const parse = (raw) => {
159
- var _a;
160
225
  if (!raw)
161
226
  return emptyStored();
162
227
  if ((lastParsed === null || lastParsed === void 0 ? void 0 : lastParsed.raw) !== raw)
163
228
  lastParsed = { raw, data: parseStored(raw) };
164
- const cutoff = Date.now() - ttlMs;
165
- const { data } = lastParsed;
166
- return {
167
- version: chatThreads_1.PERSISTED_CHAT_VERSION,
168
- threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
169
- deleted: Object.fromEntries(Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => at >= cutoff)),
170
- };
229
+ return dropExpired(lastParsed.data, Date.now() - ttlMs);
230
+ };
231
+ // Other shoppers' records expire only here: their own store may never run again on this device.
232
+ let swept = false;
233
+ const sweepOnce = () => {
234
+ if (swept)
235
+ return;
236
+ swept = true;
237
+ const local = resolveStorage(storageOption, 'local');
238
+ if (local)
239
+ sweepExpired(local, key, ttlMs).catch(() => { });
171
240
  };
172
241
  const read = () => {
242
+ sweepOnce();
173
243
  const storage = resolveStorage(storageOption, storageArea);
174
244
  return parse(storage ? readRaw(storage) : null);
175
245
  };
@@ -191,7 +261,7 @@ function createLocalStoragePersistence(options = {}) {
191
261
  /* ignore */
192
262
  }
193
263
  };
194
- const isEmpty = (data) => { var _a; return Object.keys(data.threads).length === 0 && Object.keys((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).length === 0; };
264
+ const isEmpty = (data) => entryCount(data) === 0;
195
265
  // Retries without tombstones, oldest first: the last thing to shed, see `write`.
196
266
  const tryWriteShedding = (storage, data) => {
197
267
  var _a, _b;
@@ -256,8 +326,8 @@ function createLocalStoragePersistence(options = {}) {
256
326
  tryWriteShedding(storage, others);
257
327
  };
258
328
  // Redo the merge if another tab wrote between our read and this write.
259
- const MAX_COMMIT_ATTEMPTS = 3;
260
329
  const transact = (mutate, priorityThreadId) => {
330
+ sweepOnce();
261
331
  const storage = resolveStorage(storageOption, storageArea);
262
332
  if (!storage)
263
333
  return;
@@ -305,12 +375,34 @@ function createLocalStoragePersistence(options = {}) {
305
375
  }
306
376
  return next;
307
377
  };
378
+ const abortListeners = new Set();
379
+ // Only `localStorage` is shared between tabs; a guest's `sessionStorage` thread streams in this tab alone.
380
+ const channel = storageArea === 'local'
381
+ ? openAbortChannel(`${storageKey}:abort`, (request) => abortListeners.forEach((listener) => listener(request)))
382
+ : null;
383
+ const abortMethods = channel
384
+ ? {
385
+ requestAbort(threadId, messageId) {
386
+ try {
387
+ channel.postMessage({ threadId, messageId });
388
+ }
389
+ catch (_a) {
390
+ /* channel closed */
391
+ }
392
+ },
393
+ subscribeAbort(listener) {
394
+ abortListeners.add(listener);
395
+ return () => {
396
+ abortListeners.delete(listener);
397
+ };
398
+ },
399
+ }
400
+ : {};
308
401
  const retire = (data, staleId) => {
309
402
  const _a = data.threads, _b = staleId, retired = _a[_b], threads = tslib_1.__rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
310
403
  return Object.assign(Object.assign({}, data), { threads, deleted: Object.assign(Object.assign({}, data.deleted), { [staleId]: Date.now() }) });
311
404
  };
312
- return {
313
- listThreads() {
405
+ return Object.assign({ listThreads() {
314
406
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
315
407
  const now = Date.now();
316
408
  return sortedThreads(read()).map((chat) => ({
@@ -367,8 +459,7 @@ function createLocalStoragePersistence(options = {}) {
367
459
  };
368
460
  window.addEventListener('storage', onStorage);
369
461
  return () => window.removeEventListener('storage', onStorage);
370
- },
371
- };
462
+ } }, abortMethods);
372
463
  }
373
464
  exports.createLocalStoragePersistence = createLocalStoragePersistence;
374
465
  const sharedStores = new Map();