@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
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.markdownToText = exports.renderMarkdown = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const dompurify_1 = tslib_1.__importDefault(require("dompurify"));
6
+ const marked_1 = require("marked");
7
+ // The text comes from an LLM a shopper can steer, so only the tags `marked` outputs are kept.
8
+ // Images, media and SVG would load a URL with no click and could leak data; form controls and
9
+ // `style` could fake a form.
10
+ const purifyConfig = {
11
+ 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(' '),
12
+ ALLOWED_ATTR: ['href', 'tabindex', 'align', 'start'],
13
+ };
14
+ // Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
15
+ // links to this site, its subdomains, email and phone stay clickable.
16
+ function isOnThisSite(href) {
17
+ try {
18
+ const url = new URL(href, window.location.href);
19
+ if (url.protocol === 'mailto:' || url.protocol === 'tel:')
20
+ return true;
21
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
22
+ return false;
23
+ const site = window.location.hostname.replace(/^www\./, '');
24
+ return url.hostname === site || url.hostname.endsWith(`.${site}`);
25
+ }
26
+ catch (_a) {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Turns the agent's markdown into sanitized HTML: paragraphs, bold, links, lists and tables.
32
+ * Links to other sites become plain text. Browser only: without a DOM to sanitize with (server
33
+ * rendering) it returns `''`, so render the plain text instead.
34
+ */
35
+ function renderMarkdown(markdown) {
36
+ if (!markdown || !dompurify_1.default.isSupported)
37
+ return '';
38
+ const html = marked_1.marked.parse(markdown, { async: false, breaks: true })
39
+ // A wide table scrolls (AiMessage.css), so keyboard users need to be able to focus it.
40
+ .replace(/<table>/g, '<table tabindex="0">');
41
+ const doc = new DOMParser().parseFromString(dompurify_1.default.sanitize(html, purifyConfig), 'text/html');
42
+ doc.querySelectorAll('a[href]').forEach((link) => {
43
+ if (!isOnThisSite(link.getAttribute('href')))
44
+ link.replaceWith(...Array.from(link.childNodes));
45
+ });
46
+ return doc.body.innerHTML;
47
+ }
48
+ exports.renderMarkdown = renderMarkdown;
49
+ /** The agent's markdown as plain text, one space between blocks and table cells, for screen readers. */
50
+ function markdownToText(markdown) {
51
+ var _a;
52
+ const html = renderMarkdown(markdown);
53
+ if (!html)
54
+ return markdown;
55
+ // An inert document: nothing in it loads or runs.
56
+ const { body } = new DOMParser().parseFromString(html, 'text/html');
57
+ body.querySelectorAll('p, li, th, td, br').forEach((node) => node.append(' '));
58
+ return ((_a = body.textContent) !== null && _a !== void 0 ? _a : '').replace(/\s+/g, ' ').trim();
59
+ }
60
+ exports.markdownToText = markdownToText;
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.9.0';
3
+ exports.default = '1.10.0';
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
3
  import translate from '../../utils/translate';
4
+ import { renderMarkdown } from '../../utils/markdown';
4
5
  import TypingIndicator from './TypingIndicator';
5
6
  export default function AiMessage({ message, componentOverrides, translations }) {
6
7
  const isLoading = message.status === 'loading';
@@ -11,6 +12,7 @@ export default function AiMessage({ message, componentOverrides, translations })
11
12
  const errorKey = message.interrupted ? 'CioAsa.error.interrupted' : 'CioAsa.error.message';
12
13
  const text = isError ? message.text || translate(errorKey, translations) : message.text || '';
13
14
  const hasText = text.length > 0;
15
+ const html = renderMarkdown(text);
14
16
  const loaderRenderProps = { translations };
15
17
  const textRenderProps = { text };
16
18
  return (React.createElement("div", { className: 'cio-asa-ai-message' },
@@ -22,7 +24,9 @@ export default function AiMessage({ message, componentOverrides, translations })
22
24
  isError && 'cio-asa-ai-message__bubble--error',
23
25
  ]
24
26
  .filter(Boolean)
25
- .join(' '), role: isError ? 'alert' : undefined },
26
- React.createElement("div", { className: 'cio-asa-ai-message__text' }, text))))));
27
+ .join(' '), role: isError ? 'alert' : undefined }, html ? (React.createElement("div", { className: 'cio-asa-ai-message__text',
28
+ // Sanitized by renderMarkdown
29
+ // eslint-disable-next-line react/no-danger
30
+ dangerouslySetInnerHTML: { __html: html } })) : (React.createElement("div", { className: 'cio-asa-ai-message__text' }, text)))))));
27
31
  }
28
32
  AiMessage.displayName = 'AiMessage';
@@ -5,6 +5,7 @@ import useFocusTrap from '../../hooks/useFocusTrap';
5
5
  import useLatest from '../../hooks/useLatest';
6
6
  import { normalizeItemToProduct } from '../../utils/productNormalizer';
7
7
  import translate from '../../utils/translate';
8
+ import { markdownToText } from '../../utils/markdown';
8
9
  import ChatHeader from './ChatHeader';
9
10
  import WelcomeScreen from './WelcomeScreen';
10
11
  import ChatMessageList from './ChatMessageList';
@@ -21,7 +22,7 @@ function getAnnouncement(messages, translations) {
21
22
  return `${youSaid}: ${userText}. ${typing}`;
22
23
  }
23
24
  if (lastMessage.status === 'done' && lastMessage.text) {
24
- return `${translate('CioAsa.aiMessage.ariaLabel', translations)}: ${lastMessage.text}`;
25
+ return `${translate('CioAsa.aiMessage.ariaLabel', translations)}: ${markdownToText(lastMessage.text)}`;
25
26
  }
26
27
  return '';
27
28
  }
@@ -9,8 +9,7 @@ export default function ChatInput({ onSubmit, isDisabled = false, isStreaming =
9
9
  isDisabled,
10
10
  submitOnEnterOnly: true,
11
11
  });
12
- // The stop button only makes sense when there is something to stop and a way to do it.
13
- const canAbort = isStreaming && showStopButton && typeof onAbort === 'function';
12
+ const canAbort = isStreaming && typeof onAbort === 'function';
14
13
  const renderProps = {
15
14
  value,
16
15
  onChange: setValue,
@@ -18,13 +17,14 @@ export default function ChatInput({ onSubmit, isDisabled = false, isStreaming =
18
17
  placeholder: translate('CioAsa.input.placeholder', translations),
19
18
  isDisabled,
20
19
  isStreaming,
20
+ canAbort,
21
21
  onAbort: onAbort ?? (() => { }),
22
22
  };
23
23
  return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
24
24
  React.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
25
25
  React.createElement("div", { className: 'cio-asa-chat-input' },
26
26
  React.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": translate('CioAsa.input.ariaLabel', translations) }),
27
- canAbort ? (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": translate('CioAsa.input.stopAriaLabel', translations) },
27
+ canAbort && showStopButton ? (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": translate('CioAsa.input.stopAriaLabel', translations) },
28
28
  React.createElement(StopIcon, null))) : (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": translate('CioAsa.input.sendAriaLabel', translations) },
29
29
  React.createElement(SendArrowIcon, null)))))));
30
30
  }
@@ -45,7 +45,7 @@ export function handleMessage(data, assistantId, setMessages) {
45
45
  updateMessageById(setMessages, assistantId, (msg) => ({
46
46
  ...msg,
47
47
  status: 'streaming',
48
- text: (msg.text || '') + (data?.text || ''),
48
+ text: [msg.text, data?.text].filter(Boolean).join('\n'),
49
49
  }));
50
50
  }
51
51
  export function handleFollowUpRefinement(data, assistantId, setMessages) {
@@ -1,5 +1,5 @@
1
1
  import { prepareSnapshot } from '../../utils/chatSession';
2
- import { normalizeHydratedMessages, saveThreadAndRetireStale } from '../../utils/chatThreads';
2
+ import { normalizeHydratedMessages, saveThreadAndRetireStale, settleCancelledReply, } from '../../utils/chatThreads';
3
3
  const turns = new Set();
4
4
  const listeners = new Set();
5
5
  const notify = () => listeners.forEach((listener) => listener());
@@ -17,6 +17,7 @@ export class BackgroundTurn {
17
17
  ids = new Set();
18
18
  /** Its writes, in order; a chat that takes the turn back writes after them. */
19
19
  writes;
20
+ stopListening;
20
21
  constructor(turn, params) {
21
22
  this.turn = turn;
22
23
  this.store = params.store;
@@ -35,6 +36,9 @@ export class BackgroundTurn {
35
36
  this.save().then(notify);
36
37
  },
37
38
  onDone: () => {
39
+ if (!this.streaming)
40
+ return;
41
+ this.release();
38
42
  this.streaming = false;
39
43
  this.messages = normalizeHydratedMessages(this.messages);
40
44
  this.save().then(() => {
@@ -65,6 +69,34 @@ export class BackgroundTurn {
65
69
  /** Later writes go into `store`. */
66
70
  moveTo(store) {
67
71
  this.store = store;
72
+ if (this.streaming)
73
+ this.listen();
74
+ }
75
+ /** Stops the answer when another tab asks to, through the store it is saved into. */
76
+ listen() {
77
+ this.release();
78
+ this.stopListening = this.store.subscribeAbort?.(({ messageId }) => {
79
+ if (messageId === this.turn.assistantId)
80
+ this.abort();
81
+ });
82
+ }
83
+ release() {
84
+ this.stopListening?.();
85
+ this.stopListening = undefined;
86
+ }
87
+ /** Stops the answer and stores it settled, as the stop button does on screen. */
88
+ abort() {
89
+ if (!this.streaming)
90
+ return;
91
+ this.streaming = false;
92
+ this.release();
93
+ this.turn.cancel();
94
+ this.messages = settleCancelledReply(this.messages, this.turn.assistantId);
95
+ this.save().then(() => {
96
+ if (this.saved)
97
+ turns.delete(this);
98
+ notify();
99
+ });
68
100
  }
69
101
  /** Stores the conversation settled, without awaiting: the page is going away. */
70
102
  flush() {
@@ -85,6 +117,7 @@ let listeningForPagehide = false;
85
117
  export function continueInBackground(turn, params) {
86
118
  const bg = new BackgroundTurn(turn, params);
87
119
  turns.add(bg);
120
+ bg.listen();
88
121
  if (!listeningForPagehide && typeof window !== 'undefined') {
89
122
  listeningForPagehide = true;
90
123
  window.addEventListener('pagehide', () => turns.forEach((t) => t.flush()));
@@ -100,6 +133,7 @@ export function resumeBackgroundTurn(store, threadId, createOwner) {
100
133
  if (!bg)
101
134
  return null;
102
135
  turns.delete(bg);
136
+ bg.release();
103
137
  if (bg.streaming)
104
138
  bg.turn.owner = createOwner(bg.turn);
105
139
  return bg;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, } from 'react';
2
- import { findRekeyedThread, getTabId, foreignStreamRemainingMs, isInFlight, mergeMessages, isLocalThreadId, moveThreads, normalizeHydratedMessages, saveThreadAndRetireStale, } from '../../utils/chatThreads';
2
+ import { FOREIGN_ABORT_TIMEOUT_MS, findRekeyedThread, getTabId, foreignStreamRemainingMs, isInFlight, mergeMessages, isLocalThreadId, moveThreads, normalizeHydratedMessages, saveThreadAndRetireStale, } from '../../utils/chatThreads';
3
3
  import { adoptSession, adoptStoredChat, forkSession, isOwnMessage, prepareSnapshot, resetConversation, } from '../../utils/chatSession';
4
4
  import useIsMounted from '../useIsMounted';
5
5
  import useLatest from '../useLatest';
@@ -7,10 +7,12 @@ import useWriteQueue from './useWriteQueue';
7
7
  import useThreadList from './useThreadList';
8
8
  import useForeignStream from './useForeignStream';
9
9
  import { continueInBackground, moveBackgroundTurns, resumeBackgroundTurn, subscribeBackgroundTurns, } from './backgroundTurns';
10
+ /** Whether `store` can both ask another tab to stop an answer and hear such a request. */
11
+ const canAbortAcrossTabs = (store) => Boolean(store?.requestAbort && store.subscribeAbort);
10
12
  const hasUnsavedWork = (session) => session.isStreaming || session.dirty || session.orphanIds.length > 0;
11
13
  /** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
12
14
  export default function useChatPersistence(params) {
13
- const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, } = params;
15
+ const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, abortTurn, } = params;
14
16
  if (store)
15
17
  getTabId();
16
18
  const [isHydrating, setIsHydrating] = useState(Boolean(store));
@@ -58,6 +60,15 @@ export default function useChatPersistence(params) {
58
60
  setIsHydrating(Boolean(store));
59
61
  }
60
62
  }
63
+ /** Settles the answer another tab left in flight, and writes it back. */
64
+ const settleForeign = useCallback((threadId) => {
65
+ if (!mountedRef.current || session.storageThreadId !== threadId)
66
+ return;
67
+ if (session.isStreaming)
68
+ return;
69
+ session.dirty = true;
70
+ setMessages((prev) => normalizeHydratedMessages(prev));
71
+ }, [session, setMessages, mountedRef]);
61
72
  /** Puts a stored conversation on screen and makes it the one this tab continues. */
62
73
  const showStoredChat = useCallback((chat) => {
63
74
  adoptStoredChat(session, chat);
@@ -72,15 +83,18 @@ export default function useChatPersistence(params) {
72
83
  return;
73
84
  }
74
85
  setMessages(chat.messages);
75
- watchForeign(remainingMs, () => {
76
- if (!mountedRef.current || session.storageThreadId !== chat.threadId)
77
- return;
78
- if (session.isStreaming)
79
- return;
80
- session.dirty = true;
81
- setMessages((prev) => normalizeHydratedMessages(prev));
82
- });
83
- }, [session, stopForeign, watchForeign, setMessages, mountedRef]);
86
+ watchForeign(remainingMs, () => settleForeign(chat.threadId));
87
+ }, [session, stopForeign, watchForeign, settleForeign, setMessages]);
88
+ /** Asks the tab streaming the answer on screen to stop it; settles it here if no tab does. */
89
+ const abortForeign = useCallback(() => {
90
+ const { current } = storeRef;
91
+ const threadId = session.storageThreadId;
92
+ const last = messagesRef.current[messagesRef.current.length - 1];
93
+ if (!canAbortAcrossTabs(current) || !session.foreignInFlight || !threadId || !last)
94
+ return;
95
+ current.requestAbort(threadId, last.id);
96
+ watchForeign(FOREIGN_ABORT_TIMEOUT_MS, () => settleForeign(threadId));
97
+ }, [session, storeRef, messagesRef, watchForeign, settleForeign]);
84
98
  /** Writes the conversation as it is now; `settle` finishes an in-flight answer, `sync` skips awaiting. */
85
99
  const persistNow = useCallback((mode) => {
86
100
  const { current } = storeRef;
@@ -320,6 +334,14 @@ export default function useChatPersistence(params) {
320
334
  settleAndPersist();
321
335
  }, [detachTurn, settleAndPersist]);
322
336
  useEffect(() => subscribeBackgroundTurns(refreshThreads), [refreshThreads]);
337
+ useEffect(() => {
338
+ if (!store?.subscribeAbort)
339
+ return undefined;
340
+ return store.subscribeAbort(({ messageId }) => {
341
+ if (turnRef.current?.assistantId === messageId)
342
+ abortTurn();
343
+ });
344
+ }, [store, turnRef, abortTurn]);
323
345
  // Follow changes another tab made to the list or to the thread on screen.
324
346
  const syncFromStore = useCallback(async () => {
325
347
  const { current } = storeRef;
@@ -465,6 +487,8 @@ export default function useChatPersistence(params) {
465
487
  threads,
466
488
  activeThreadId,
467
489
  foreignInFlight,
490
+ canAbortForeign: foreignInFlight && canAbortAcrossTabs(store),
491
+ abortForeign,
468
492
  beginTurn,
469
493
  screenOwner,
470
494
  clearHistory,
@@ -5,7 +5,7 @@ import useChatPersistence from './persistence/useChatPersistence';
5
5
  import { startTurn } from './agentStream';
6
6
  import { createChatSession, nextMessageId } from '../utils/chatSession';
7
7
  import { normalizeItemToProduct } from '../utils/productNormalizer';
8
- import { isLocalThreadId } from '../utils/chatThreads';
8
+ import { isLocalThreadId, settleCancelledReply } from '../utils/chatThreads';
9
9
  import useLatest from './useLatest';
10
10
  export default function useAsaResults(options) {
11
11
  const contextValue = useCioAsaContext();
@@ -39,6 +39,18 @@ export default function useAsaResults(options) {
39
39
  turnRef.current?.cancel();
40
40
  turnRef.current = null;
41
41
  }, []);
42
+ const abortOwnTurn = useCallback(() => {
43
+ if (!session.isStreaming)
44
+ return;
45
+ const abortedId = turnRef.current?.assistantId;
46
+ cancelStream();
47
+ session.isStreaming = false;
48
+ setIsStreaming(false);
49
+ if (abortedId)
50
+ setMessages((prev) => settleCancelledReply(prev, abortedId));
51
+ // With persistence on, the cancelled turn is stored as it stands.
52
+ session.dirty = true;
53
+ }, [session, cancelStream]);
42
54
  const store = useChatPersistence({
43
55
  store: persistence,
44
56
  scope: persistenceScope,
@@ -51,8 +63,9 @@ export default function useAsaResults(options) {
51
63
  setIsStreaming,
52
64
  turnRef,
53
65
  cancelStream,
66
+ abortTurn: abortOwnTurn,
54
67
  });
55
- const { beginTurn, screenOwner } = store;
68
+ const { beginTurn, screenOwner, abortForeign } = store;
56
69
  useEffect(() => cancelStream, [cancelStream]);
57
70
  const sendToCurrentThread = useCallback((text, source) => {
58
71
  const intent = text.trim();
@@ -149,31 +162,20 @@ export default function useAsaResults(options) {
149
162
  * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
150
163
  * finished would be false. An aborted turn therefore leaves an
151
164
  * `assistant_result_load_started` with no matching finished event.
165
+ *
166
+ * An answer streaming in another tab is stopped by that tab, which stores the result.
152
167
  */
153
168
  const abort = useCallback(() => {
154
- if (!session.isStreaming)
155
- return;
156
- const abortedId = turnRef.current?.assistantId;
157
- cancelStream();
158
- session.isStreaming = false;
159
- setIsStreaming(false);
160
- if (abortedId) {
161
- setMessages((prev) => prev.flatMap((msg) => {
162
- if (msg.id !== abortedId)
163
- return [msg];
164
- if (!msg.text && !msg.groups?.length && !msg.refinement)
165
- return [];
166
- return [{ ...msg, status: 'done' }];
167
- }));
168
- }
169
- // With persistence on, the cancelled turn is stored as it stands.
170
- session.dirty = true;
171
- }, [session, cancelStream]);
169
+ if (session.isStreaming)
170
+ abortOwnTurn();
171
+ else
172
+ abortForeign();
173
+ }, [session, abortOwnTurn, abortForeign]);
172
174
  return {
173
175
  messages,
174
176
  sendMessage,
175
177
  isStreaming: isStreaming || store.foreignInFlight,
176
- canAbort: isStreaming,
178
+ canAbort: isStreaming || store.canAbortForeign,
177
179
  abort,
178
180
  clearHistory: store.clearHistory,
179
181
  isHydrating: store.isHydrating,
package/lib/mjs/index.js CHANGED
@@ -11,5 +11,6 @@ export { default as useCioAsaContext } from './hooks/useCioAsaContext';
11
11
  // Utils
12
12
  export { normalizeItemToProduct } from './utils/productNormalizer';
13
13
  export { clearPersistedConversations } from './utils/localStoragePersistence';
14
+ export { renderMarkdown } from './utils/markdown';
14
15
  // Re-exported client value from the JS client (used to construct/type a cioClient)
15
16
  export { ConstructorIOClient } from './types';
@@ -6,6 +6,8 @@ export const DEFAULT_PERSISTENCE_KEY = 'cio-asa:chat';
6
6
  export const DEFAULT_PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7
7
  /** How long a stored answer written by another tab is still shown as "typing" before it is settled. */
8
8
  export const IN_FLIGHT_GRACE_MS = 60 * 1000;
9
+ /** How long to wait for the tab streaming an answer to store it stopped, before settling it here. */
10
+ export const FOREIGN_ABORT_TIMEOUT_MS = 3 * 1000;
9
11
  /** Marks client-side thread ids, used before the server has assigned one. Never sent to the agent. */
10
12
  const LOCAL_THREAD_PREFIX = 'local-';
11
13
  /** Thread titles are the first question, cut to this many characters. */
@@ -111,6 +113,16 @@ export function normalizeHydratedMessages(messages) {
111
113
  return hasContent ? { ...m, status: 'done' } : { ...m, status: 'error', interrupted: true };
112
114
  });
113
115
  }
116
+ /** `messages` with the cancelled reply settled: `done` when it has content, dropped otherwise. */
117
+ export function settleCancelledReply(messages, assistantId) {
118
+ return messages.flatMap((m) => {
119
+ if (m.id !== assistantId)
120
+ return [m];
121
+ if (!m.text && !m.groups?.length && !m.refinement)
122
+ return [];
123
+ return [{ ...m, status: 'done' }];
124
+ });
125
+ }
114
126
  /** Messages as stored: raw items replaced by the cards `normalize` makes; a restored group keeps its cards. */
115
127
  export function compactMessages(messages, normalize) {
116
128
  return messages.map((m) => m.groups?.length