@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.
- package/README.md +8 -210
- package/dist/constructorio-ui-asa-bundled.js +78 -20
- package/lib/cjs/components/Chat/AiMessage.css +52 -0
- package/lib/cjs/components/Chat/AiMessage.js +6 -2
- package/lib/cjs/components/Chat/Chat.js +2 -1
- package/lib/cjs/components/Chat/ChatInput.js +3 -3
- package/lib/cjs/hooks/asaStreamHandlers.js +1 -1
- package/lib/cjs/hooks/persistence/backgroundTurns.js +35 -0
- package/lib/cjs/hooks/persistence/useChatPersistence.js +35 -10
- package/lib/cjs/hooks/useAsaResults.js +23 -22
- package/lib/cjs/index.js +3 -1
- package/lib/cjs/utils/chatThreads.js +15 -1
- package/lib/cjs/utils/localStoragePersistence.js +110 -19
- package/lib/cjs/utils/markdown.js +60 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/Chat/AiMessage.css +52 -0
- package/lib/mjs/components/Chat/AiMessage.js +6 -2
- package/lib/mjs/components/Chat/Chat.js +2 -1
- package/lib/mjs/components/Chat/ChatInput.js +3 -3
- package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
- package/lib/mjs/hooks/persistence/backgroundTurns.js +35 -1
- package/lib/mjs/hooks/persistence/useChatPersistence.js +35 -11
- package/lib/mjs/hooks/useAsaResults.js +23 -21
- package/lib/mjs/index.js +1 -0
- package/lib/mjs/utils/chatThreads.js +12 -0
- package/lib/mjs/utils/localStoragePersistence.js +106 -14
- package/lib/mjs/utils/markdown.js +53 -0
- package/lib/mjs/version.js +1 -1
- package/lib/types/hooks/persistence/backgroundTurns.d.ts +6 -0
- package/lib/types/hooks/persistence/useChatPersistence.d.ts +4 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/types.d.ts +21 -5
- package/lib/types/utils/chatThreads.d.ts +4 -0
- package/lib/types/utils/markdown.d.ts +8 -0
- package/lib/types/version.d.ts +1 -1
- 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;
|
package/lib/cjs/version.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|