@constructor-io/constructorio-ui-asa 1.4.0 → 1.6.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 +1 -1
- package/dist/constructorio-ui-asa-bundled.js +1 -1
- package/lib/cjs/components/Chat/Chat.js +5 -4
- package/lib/cjs/components/Chat/ChatInput.css +11 -0
- package/lib/cjs/components/Chat/ChatInput.js +8 -3
- package/lib/cjs/components/Chat/ChatMessageList.js +7 -3
- package/lib/cjs/components/Chat/FollowUpRefinement.css +62 -0
- package/lib/cjs/components/Chat/FollowUpRefinement.js +30 -0
- package/lib/cjs/components/icons/StopIcon.js +9 -0
- package/lib/cjs/components/icons/index.js +3 -1
- package/lib/cjs/hooks/asaStreamHandlers.js +11 -1
- package/lib/cjs/hooks/useAsaResults.js +62 -13
- package/lib/cjs/styles.css +1 -0
- package/lib/cjs/utils/translate.js +2 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/Chat/Chat.js +5 -4
- package/lib/mjs/components/Chat/ChatInput.css +11 -0
- package/lib/mjs/components/Chat/ChatInput.js +9 -4
- package/lib/mjs/components/Chat/ChatMessageList.js +7 -3
- package/lib/mjs/components/Chat/FollowUpRefinement.css +62 -0
- package/lib/mjs/components/Chat/FollowUpRefinement.js +26 -0
- package/lib/mjs/components/icons/StopIcon.js +5 -0
- package/lib/mjs/components/icons/index.js +1 -0
- package/lib/mjs/hooks/asaStreamHandlers.js +13 -0
- package/lib/mjs/hooks/useAsaResults.js +62 -14
- package/lib/mjs/styles.css +1 -0
- package/lib/mjs/utils/translate.js +2 -0
- package/lib/mjs/version.js +1 -1
- package/lib/types/components/Chat/Chat.d.ts +13 -0
- package/lib/types/components/Chat/ChatInput.d.ts +11 -1
- package/lib/types/components/Chat/ChatMessageList.d.ts +6 -2
- package/lib/types/components/Chat/FollowUpRefinement.d.ts +14 -0
- package/lib/types/components/icons/StopIcon.d.ts +2 -0
- package/lib/types/components/icons/index.d.ts +1 -0
- package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/types.d.ts +35 -3
- package/lib/types/version.d.ts +1 -1
- package/package.json +3 -3
|
@@ -14,6 +14,9 @@ function useAsaResults(options) {
|
|
|
14
14
|
const readerRef = (0, react_1.useRef)(null);
|
|
15
15
|
const threadIdRef = (0, react_1.useRef)((_a = options === null || options === void 0 ? void 0 : options.initialThreadId) !== null && _a !== void 0 ? _a : null);
|
|
16
16
|
const idCounterRef = (0, react_1.useRef)(0);
|
|
17
|
+
// The assistant message the in-flight stream is writing into, so `abort` can
|
|
18
|
+
// settle it without having to guess which message that is.
|
|
19
|
+
const activeAssistantIdRef = (0, react_1.useRef)(null);
|
|
17
20
|
const contextValue = (0, useCioAsaContext_1.useCioAsaContext)();
|
|
18
21
|
if (!contextValue) {
|
|
19
22
|
throw new Error('useAsaResults must be used within a CioAsaProvider.');
|
|
@@ -34,6 +37,21 @@ function useAsaResults(options) {
|
|
|
34
37
|
callbacksRef.current = callbacks;
|
|
35
38
|
const staticRequestConfigsRef = (0, react_1.useRef)(staticRequestConfigs);
|
|
36
39
|
staticRequestConfigsRef.current = staticRequestConfigs;
|
|
40
|
+
/**
|
|
41
|
+
* Stops the in-flight stream. The kill switch is raised *before* cancelling because
|
|
42
|
+
* cancelling makes the pending `reader.read()` resolve `{ done: true }` — identical
|
|
43
|
+
* to a stream that ended on its own. Without the flag already set, the read loop
|
|
44
|
+
* would take the end-of-stream branch and report a load that never finished.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately touches no state, so the unmount cleanup can share it.
|
|
47
|
+
*/
|
|
48
|
+
const teardownStream = (0, react_1.useCallback)(() => {
|
|
49
|
+
killSwitchRef.current = true;
|
|
50
|
+
if (readerRef.current) {
|
|
51
|
+
readerRef.current.cancel();
|
|
52
|
+
readerRef.current = null;
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
37
55
|
const nextMessageId = (0, react_1.useCallback)(() => {
|
|
38
56
|
idCounterRef.current += 1;
|
|
39
57
|
return `msg-${idCounterRef.current}-${Date.now()}`;
|
|
@@ -59,6 +77,7 @@ function useAsaResults(options) {
|
|
|
59
77
|
status: 'loading',
|
|
60
78
|
intent,
|
|
61
79
|
};
|
|
80
|
+
activeAssistantIdRef.current = assistantMessage.id;
|
|
62
81
|
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
|
63
82
|
setIsStreaming(true);
|
|
64
83
|
isStreamingRef.current = true;
|
|
@@ -142,6 +161,10 @@ function useAsaResults(options) {
|
|
|
142
161
|
fireLoadStart();
|
|
143
162
|
(0, asaStreamHandlers_1.handleMessage)(data, assistantMessage.id, setMessages);
|
|
144
163
|
}
|
|
164
|
+
else if (type === 'follow_up_refinement') {
|
|
165
|
+
fireLoadStart();
|
|
166
|
+
(0, asaStreamHandlers_1.handleFollowUpRefinement)(data, assistantMessage.id, setMessages);
|
|
167
|
+
}
|
|
145
168
|
else if (type === 'server_error') {
|
|
146
169
|
(0, asaStreamHandlers_1.handleServerError)(assistantMessage.id, setMessages);
|
|
147
170
|
break;
|
|
@@ -155,30 +178,56 @@ function useAsaResults(options) {
|
|
|
155
178
|
if (readerRef.current === reader) {
|
|
156
179
|
reader.cancel();
|
|
157
180
|
readerRef.current = null;
|
|
181
|
+
activeAssistantIdRef.current = null;
|
|
158
182
|
setIsStreaming(false);
|
|
159
183
|
isStreamingRef.current = false;
|
|
160
184
|
}
|
|
161
185
|
}
|
|
162
186
|
}))();
|
|
163
187
|
}, [cioClient, domain, nextMessageId]);
|
|
164
|
-
(0, react_1.useEffect)(() => () =>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
(0, react_1.useEffect)(() => () => teardownStream(), [teardownStream]);
|
|
189
|
+
/**
|
|
190
|
+
* Cancels the in-flight request while keeping the conversation: the partial reply is
|
|
191
|
+
* settled as `done` (so the typing indicator stops and streamed text survives) and the
|
|
192
|
+
* thread id is kept, so the next message continues the same conversation rather than
|
|
193
|
+
* starting a new one. Use `clearHistory` to reset instead.
|
|
194
|
+
*
|
|
195
|
+
* A reply that had streamed nothing yet is dropped rather than settled — it would
|
|
196
|
+
* otherwise render as a blank bubble, and a cancelled turn would read as a glitch.
|
|
197
|
+
* "Nothing" means no text, no product groups and no follow-up refinement; any one of
|
|
198
|
+
* them is content worth keeping. The user's own message always stays.
|
|
199
|
+
*
|
|
200
|
+
* No beacon is sent: there is no "aborted" ASA event, and reporting the load as
|
|
201
|
+
* finished would be false. An aborted turn therefore leaves an
|
|
202
|
+
* `assistant_result_load_started` with no matching finished event.
|
|
203
|
+
*/
|
|
204
|
+
const abort = (0, react_1.useCallback)(() => {
|
|
205
|
+
if (!isStreamingRef.current)
|
|
206
|
+
return;
|
|
207
|
+
teardownStream();
|
|
208
|
+
setIsStreaming(false);
|
|
209
|
+
isStreamingRef.current = false;
|
|
210
|
+
const abortedId = activeAssistantIdRef.current;
|
|
211
|
+
activeAssistantIdRef.current = null;
|
|
212
|
+
if (abortedId) {
|
|
213
|
+
setMessages((prev) => prev.flatMap((msg) => {
|
|
214
|
+
var _a;
|
|
215
|
+
if (msg.id !== abortedId)
|
|
216
|
+
return [msg];
|
|
217
|
+
if (!msg.text && !((_a = msg.groups) === null || _a === void 0 ? void 0 : _a.length) && !msg.refinement)
|
|
218
|
+
return [];
|
|
219
|
+
return [Object.assign(Object.assign({}, msg), { status: 'done' })];
|
|
220
|
+
}));
|
|
169
221
|
}
|
|
170
|
-
}, []);
|
|
222
|
+
}, [teardownStream]);
|
|
171
223
|
const clearHistory = (0, react_1.useCallback)(() => {
|
|
172
|
-
|
|
173
|
-
if (readerRef.current) {
|
|
174
|
-
readerRef.current.cancel();
|
|
175
|
-
readerRef.current = null;
|
|
176
|
-
}
|
|
224
|
+
teardownStream();
|
|
177
225
|
threadIdRef.current = null;
|
|
226
|
+
activeAssistantIdRef.current = null;
|
|
178
227
|
setMessages([]);
|
|
179
228
|
setIsStreaming(false);
|
|
180
229
|
isStreamingRef.current = false;
|
|
181
|
-
}, []);
|
|
182
|
-
return { messages, sendMessage, isStreaming, clearHistory };
|
|
230
|
+
}, [teardownStream]);
|
|
231
|
+
return { messages, sendMessage, isStreaming, abort, clearHistory };
|
|
183
232
|
}
|
|
184
233
|
exports.default = useAsaResults;
|
package/lib/cjs/styles.css
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
@import './components/Chat/ChatMessageList.css';
|
|
7
7
|
@import './components/Chat/UserMessage.css';
|
|
8
8
|
@import './components/Chat/AiMessage.css';
|
|
9
|
+
@import './components/Chat/FollowUpRefinement.css';
|
|
9
10
|
@import './components/Chat/TypingIndicator.css';
|
|
10
11
|
@import './components/Chat/ChatInput.css';
|
|
11
12
|
@import './components/ResultsBlock/ResultsBlock.css';
|
|
@@ -6,6 +6,7 @@ const defaultTranslations = {
|
|
|
6
6
|
'CioAsa.input.placeholder': 'Ask a question about this product',
|
|
7
7
|
'CioAsa.input.ariaLabel': 'Type your message',
|
|
8
8
|
'CioAsa.input.sendAriaLabel': 'Send message',
|
|
9
|
+
'CioAsa.input.stopAriaLabel': 'Stop response',
|
|
9
10
|
'CioAsa.welcome.title': 'Shopping Assistant',
|
|
10
11
|
'CioAsa.welcome.placeholder': 'Ask anything',
|
|
11
12
|
'CioAsa.welcome.sendButton': 'Chat',
|
|
@@ -18,6 +19,7 @@ const defaultTranslations = {
|
|
|
18
19
|
'CioAsa.results.viewMore': 'View more products',
|
|
19
20
|
'CioAsa.results.addToCart': 'Add to cart',
|
|
20
21
|
'CioAsa.results.saleBadge': 'Sale',
|
|
22
|
+
'CioAsa.refinement.ariaLabel': 'Refine your results',
|
|
21
23
|
'CioAsa.error.message': "I can't assist you with that request.",
|
|
22
24
|
};
|
|
23
25
|
function translate(key, translations) {
|
package/lib/cjs/version.js
CHANGED
|
@@ -22,8 +22,8 @@ function getAnnouncement(messages, translations) {
|
|
|
22
22
|
}
|
|
23
23
|
return '';
|
|
24
24
|
}
|
|
25
|
-
const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, }, ref) => {
|
|
26
|
-
const { messages, sendMessage, isStreaming, clearHistory } = useAsaResults({
|
|
25
|
+
const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, showStopButton = false, }, ref) => {
|
|
26
|
+
const { messages, sendMessage, isStreaming, abort, clearHistory } = useAsaResults({
|
|
27
27
|
initialThreadId,
|
|
28
28
|
});
|
|
29
29
|
const chatViewRef = useRef(null);
|
|
@@ -32,6 +32,7 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
|
|
|
32
32
|
const isModal = typeof onClose === 'function';
|
|
33
33
|
const announcement = getAnnouncement(messages, translations);
|
|
34
34
|
useImperativeHandle(ref, () => ({
|
|
35
|
+
abort,
|
|
35
36
|
clearHistory,
|
|
36
37
|
}));
|
|
37
38
|
useEffect(() => {
|
|
@@ -54,8 +55,8 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
|
|
|
54
55
|
React.createElement("div", { className: 'cio-asa-chat-body' }, isWelcome ? (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--welcome' },
|
|
55
56
|
React.createElement(WelcomeScreen, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides?.welcomeScreen }))) : (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
|
|
56
57
|
React.createElement(ChatHeader, { onClose: onClose, translations: translations, componentOverrides: componentOverrides?.header }),
|
|
57
|
-
React.createElement(ChatMessageList, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: translate('CioAsa.results.addToCart', translations), viewMoreText: translate('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides?.aiMessage, userMessageOverrides: componentOverrides?.userMessage, resultsBlockOverrides: componentOverrides?.resultsBlock, translations: translations }),
|
|
58
|
-
React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides?.input }))))));
|
|
58
|
+
React.createElement(ChatMessageList, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: translate('CioAsa.results.addToCart', translations), viewMoreText: translate('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides?.aiMessage, userMessageOverrides: componentOverrides?.userMessage, resultsBlockOverrides: componentOverrides?.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
|
|
59
|
+
React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, isStreaming: isStreaming, onAbort: abort, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides?.input }))))));
|
|
59
60
|
});
|
|
60
61
|
Chat.displayName = 'Chat';
|
|
61
62
|
export default Chat;
|
|
@@ -81,3 +81,14 @@
|
|
|
81
81
|
cursor: default;
|
|
82
82
|
opacity: 0.3;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/* Replaces the send button while a reply streams; always actionable, so it never
|
|
86
|
+
inherits the disabled styling above. */
|
|
87
|
+
.cio-asa-chat-input__stop {
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
opacity: 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.cio-asa-chat-input__stop:hover rect {
|
|
93
|
+
fill-opacity: 1;
|
|
94
|
+
}
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
|
|
3
|
-
import { SendArrowIcon } from '../icons';
|
|
3
|
+
import { SendArrowIcon, StopIcon } from '../icons';
|
|
4
4
|
import translate from '../../utils/translate';
|
|
5
5
|
import useMessageInput from '../../hooks/useMessageInput';
|
|
6
|
-
export default function ChatInput({ onSubmit, isDisabled = false, translations, componentOverrides, }) {
|
|
6
|
+
export default function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort, showStopButton = false, translations, componentOverrides, }) {
|
|
7
7
|
const { value, setValue, handleSubmit, handleKeyDown } = useMessageInput({
|
|
8
8
|
onSend: onSubmit,
|
|
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
14
|
const renderProps = {
|
|
13
15
|
value,
|
|
14
16
|
onChange: setValue,
|
|
15
17
|
onSubmit: handleSubmit,
|
|
16
18
|
placeholder: translate('CioAsa.input.placeholder', translations),
|
|
17
19
|
isDisabled,
|
|
20
|
+
isStreaming,
|
|
21
|
+
onAbort: onAbort ?? (() => { }),
|
|
18
22
|
};
|
|
19
23
|
return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
|
|
20
24
|
React.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
|
|
21
25
|
React.createElement("div", { className: 'cio-asa-chat-input' },
|
|
22
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) }),
|
|
23
|
-
React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick:
|
|
24
|
-
React.createElement(
|
|
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) },
|
|
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
|
+
React.createElement(SendArrowIcon, null)))))));
|
|
25
30
|
}
|
|
26
31
|
ChatInput.displayName = 'ChatInput';
|
|
@@ -3,7 +3,8 @@ import translate from '../../utils/translate';
|
|
|
3
3
|
import ResultsBlock from '../ResultsBlock/ResultsBlock';
|
|
4
4
|
import UserMessage from './UserMessage';
|
|
5
5
|
import AiMessage from './AiMessage';
|
|
6
|
-
|
|
6
|
+
import FollowUpRefinement from './FollowUpRefinement';
|
|
7
|
+
export default function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, onSend, isStreaming = false, }) {
|
|
7
8
|
const listRef = useRef(null);
|
|
8
9
|
const isNearBottomRef = useRef(true);
|
|
9
10
|
const prevMessageCountRef = useRef(messages.length);
|
|
@@ -28,14 +29,17 @@ export default function ChatMessageList({ messages, onProductClick, onViewMore,
|
|
|
28
29
|
el.scrollTo({ top: el.scrollHeight, behavior: isNewMessage ? 'smooth' : 'auto' });
|
|
29
30
|
});
|
|
30
31
|
}, [messages]);
|
|
31
|
-
return (React.createElement("div", { className: 'cio-asa-chat-message-list', ref: listRef, onScroll: handleScroll, role: 'log', "aria-live": 'off', tabIndex: 0, "aria-label": translate('CioAsa.messageList.ariaLabel', translations) }, messages.map((message) => {
|
|
32
|
+
return (React.createElement("div", { className: 'cio-asa-chat-message-list', ref: listRef, onScroll: handleScroll, role: 'log', "aria-live": 'off', tabIndex: 0, "aria-label": translate('CioAsa.messageList.ariaLabel', translations) }, messages.map((message, index) => {
|
|
32
33
|
if (message.role === 'user') {
|
|
33
34
|
return (React.createElement(UserMessage, { key: message.id, text: message.text, translations: translations, componentOverrides: userMessageOverrides }));
|
|
34
35
|
}
|
|
35
36
|
const hasGroups = !!message.groups?.length;
|
|
37
|
+
const isLatest = index === messages.length - 1;
|
|
38
|
+
const refinement = onSend ? message.refinement : undefined;
|
|
36
39
|
return (React.createElement("div", { key: message.id, className: 'cio-asa-ai-message-group' },
|
|
37
40
|
React.createElement(AiMessage, { message: message, componentOverrides: aiMessageOverrides, translations: translations }),
|
|
38
|
-
hasGroups && (React.createElement(ResultsBlock, { groups: message.groups, intent: message.intent, intentResultId: message.intentResultId, threadId: message.threadId, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: addToCartText, viewMoreText: viewMoreText, saleBadgeText: translate('CioAsa.results.saleBadge', translations), componentOverrides: resultsBlockOverrides }))
|
|
41
|
+
hasGroups && (React.createElement(ResultsBlock, { groups: message.groups, intent: message.intent, intentResultId: message.intentResultId, threadId: message.threadId, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: addToCartText, viewMoreText: viewMoreText, saleBadgeText: translate('CioAsa.results.saleBadge', translations), componentOverrides: resultsBlockOverrides })),
|
|
42
|
+
refinement && (React.createElement(FollowUpRefinement, { refinement: refinement, onOptionClick: (option) => onSend(option, 'refinement'), isDisabled: !isLatest || isStreaming, translations: translations, componentOverrides: aiMessageOverrides?.followUpRefinement }))));
|
|
39
43
|
})));
|
|
40
44
|
}
|
|
41
45
|
ChatMessageList.displayName = 'ChatMessageList';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
.cio-asa-follow-up-refinement {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 10px;
|
|
5
|
+
width: 100%;
|
|
6
|
+
padding-right: 12px;
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
animation: cio-asa-fade-in 0.3s ease;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.cio-asa-follow-up-refinement__question {
|
|
12
|
+
margin: 0;
|
|
13
|
+
font-weight: 500;
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
line-height: 20px;
|
|
16
|
+
letter-spacing: -0.005em;
|
|
17
|
+
color: #0F1324;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.cio-asa-follow-up-refinement__options {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
gap: 8px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.cio-asa-follow-up-refinement__chip {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
padding: 8px 14px;
|
|
31
|
+
background: #ffffff;
|
|
32
|
+
box-shadow: 0px 0.4px 1px rgba(18, 18, 105, 0.13), 0px 0.9px 6px rgba(18, 18, 105, 0.05), 0px 1px 3px rgba(18, 18, 105, 0.05);
|
|
33
|
+
border-radius: 12px;
|
|
34
|
+
border: 1px solid transparent;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
font-weight: 400;
|
|
37
|
+
font-size: 12px;
|
|
38
|
+
line-height: 20px;
|
|
39
|
+
text-align: center;
|
|
40
|
+
color: #343434;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
transition: background-color 0.2s, box-shadow 0.2s, border-color 0.2s;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.cio-asa-follow-up-refinement__chip:hover:not(:disabled) {
|
|
46
|
+
border-color: #ffffff;
|
|
47
|
+
box-shadow: 0px 6px 14px rgba(18, 18, 105, 0.11), 0px 2px 4px rgba(18, 18, 105, 0.0716748), 0px 1px 2px rgba(18, 18, 105, 0.055);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.cio-asa-follow-up-refinement__chip:active:not(:disabled) {
|
|
51
|
+
background: #E0E3EB;
|
|
52
|
+
border-color: #DEE0E3;
|
|
53
|
+
box-shadow: inset 1px 1px 2px rgba(18, 18, 105, 0.1), inset 0px 1px 4px rgba(18, 18, 105, 0.05), inset 1px 1px 1px rgba(18, 18, 105, 0.05);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.cio-asa-follow-up-refinement__chip:disabled {
|
|
57
|
+
cursor: default;
|
|
58
|
+
background: #F4F5F7;
|
|
59
|
+
border-color: #E9EAEC;
|
|
60
|
+
box-shadow: none;
|
|
61
|
+
color: #6B7080;
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
|
|
3
|
+
import translate from '../../utils/translate';
|
|
4
|
+
export default function FollowUpRefinement({ refinement, onOptionClick, isDisabled = false, translations, componentOverrides, }) {
|
|
5
|
+
const handleOptionClick = (option) => {
|
|
6
|
+
if (isDisabled)
|
|
7
|
+
return;
|
|
8
|
+
onOptionClick(option);
|
|
9
|
+
};
|
|
10
|
+
const renderProps = {
|
|
11
|
+
question: refinement.question,
|
|
12
|
+
options: refinement.options,
|
|
13
|
+
onOptionClick: handleOptionClick,
|
|
14
|
+
isDisabled,
|
|
15
|
+
};
|
|
16
|
+
return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
|
|
17
|
+
React.createElement("div", { className: [
|
|
18
|
+
'cio-asa-follow-up-refinement',
|
|
19
|
+
isDisabled && 'cio-asa-follow-up-refinement--disabled',
|
|
20
|
+
]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(' '), role: 'group', "aria-label": translate('CioAsa.refinement.ariaLabel', translations) },
|
|
23
|
+
React.createElement("p", { className: 'cio-asa-follow-up-refinement__question' }, refinement.question),
|
|
24
|
+
React.createElement("div", { className: 'cio-asa-follow-up-refinement__options' }, refinement.options.map((option) => (React.createElement("button", { key: option, type: 'button', className: 'cio-asa-follow-up-refinement__chip', disabled: isDisabled, onClick: () => handleOptionClick(option) }, option)))))));
|
|
25
|
+
}
|
|
26
|
+
FollowUpRefinement.displayName = 'FollowUpRefinement';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export default function StopIcon() {
|
|
3
|
+
return (React.createElement("svg", { width: '10', height: '10', viewBox: '0 0 10 10', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', "aria-hidden": 'true', focusable: 'false' },
|
|
4
|
+
React.createElement("rect", { x: '0.5', y: '0.5', width: '9', height: '9', rx: '1.5', fill: '#0F1324', fillOpacity: '0.7' })));
|
|
5
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default as ChatBubbleDarkIcon } from './ChatBubbleDarkIcon';
|
|
2
2
|
export { default as ChatBubbleLightIcon } from './ChatBubbleLightIcon';
|
|
3
3
|
export { default as SendArrowIcon } from './SendArrowIcon';
|
|
4
|
+
export { default as StopIcon } from './StopIcon';
|
|
4
5
|
export { default as SendPlaneIcon } from './SendPlaneIcon';
|
|
5
6
|
export { default as ArrowRightIcon } from './ArrowRightIcon';
|
|
@@ -48,6 +48,19 @@ export function handleMessage(data, assistantId, setMessages) {
|
|
|
48
48
|
text: (msg.text || '') + (data?.text || ''),
|
|
49
49
|
}));
|
|
50
50
|
}
|
|
51
|
+
export function handleFollowUpRefinement(data, assistantId, setMessages) {
|
|
52
|
+
const question = typeof data?.question === 'string' ? data.question.trim() : '';
|
|
53
|
+
const options = Array.isArray(data?.options)
|
|
54
|
+
? data.options.filter((o) => typeof o === 'string' && o.trim() !== '')
|
|
55
|
+
: [];
|
|
56
|
+
if (question === '' || options.length === 0)
|
|
57
|
+
return;
|
|
58
|
+
updateMessageById(setMessages, assistantId, (msg) => ({
|
|
59
|
+
...msg,
|
|
60
|
+
status: 'streaming',
|
|
61
|
+
refinement: { question, options },
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
51
64
|
export function handleServerError(assistantId, setMessages) {
|
|
52
65
|
updateMessageById(setMessages, assistantId, (msg) => ({
|
|
53
66
|
...msg,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useCioAsaContext } from './useCioAsaContext';
|
|
3
|
-
import { handleSearchResult, handleMessage, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
|
|
3
|
+
import { handleSearchResult, handleMessage, handleFollowUpRefinement, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
|
|
4
4
|
import useAsaTracking from './useAsaTracking';
|
|
5
5
|
export default function useAsaResults(options) {
|
|
6
6
|
const [messages, setMessages] = useState([]);
|
|
@@ -10,6 +10,9 @@ export default function useAsaResults(options) {
|
|
|
10
10
|
const readerRef = useRef(null);
|
|
11
11
|
const threadIdRef = useRef(options?.initialThreadId ?? null);
|
|
12
12
|
const idCounterRef = useRef(0);
|
|
13
|
+
// The assistant message the in-flight stream is writing into, so `abort` can
|
|
14
|
+
// settle it without having to guess which message that is.
|
|
15
|
+
const activeAssistantIdRef = useRef(null);
|
|
13
16
|
const contextValue = useCioAsaContext();
|
|
14
17
|
if (!contextValue) {
|
|
15
18
|
throw new Error('useAsaResults must be used within a CioAsaProvider.');
|
|
@@ -30,6 +33,21 @@ export default function useAsaResults(options) {
|
|
|
30
33
|
callbacksRef.current = callbacks;
|
|
31
34
|
const staticRequestConfigsRef = useRef(staticRequestConfigs);
|
|
32
35
|
staticRequestConfigsRef.current = staticRequestConfigs;
|
|
36
|
+
/**
|
|
37
|
+
* Stops the in-flight stream. The kill switch is raised *before* cancelling because
|
|
38
|
+
* cancelling makes the pending `reader.read()` resolve `{ done: true }` — identical
|
|
39
|
+
* to a stream that ended on its own. Without the flag already set, the read loop
|
|
40
|
+
* would take the end-of-stream branch and report a load that never finished.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately touches no state, so the unmount cleanup can share it.
|
|
43
|
+
*/
|
|
44
|
+
const teardownStream = useCallback(() => {
|
|
45
|
+
killSwitchRef.current = true;
|
|
46
|
+
if (readerRef.current) {
|
|
47
|
+
readerRef.current.cancel();
|
|
48
|
+
readerRef.current = null;
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
33
51
|
const nextMessageId = useCallback(() => {
|
|
34
52
|
idCounterRef.current += 1;
|
|
35
53
|
return `msg-${idCounterRef.current}-${Date.now()}`;
|
|
@@ -54,6 +72,7 @@ export default function useAsaResults(options) {
|
|
|
54
72
|
status: 'loading',
|
|
55
73
|
intent,
|
|
56
74
|
};
|
|
75
|
+
activeAssistantIdRef.current = assistantMessage.id;
|
|
57
76
|
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
|
58
77
|
setIsStreaming(true);
|
|
59
78
|
isStreamingRef.current = true;
|
|
@@ -139,6 +158,10 @@ export default function useAsaResults(options) {
|
|
|
139
158
|
fireLoadStart();
|
|
140
159
|
handleMessage(data, assistantMessage.id, setMessages);
|
|
141
160
|
}
|
|
161
|
+
else if (type === 'follow_up_refinement') {
|
|
162
|
+
fireLoadStart();
|
|
163
|
+
handleFollowUpRefinement(data, assistantMessage.id, setMessages);
|
|
164
|
+
}
|
|
142
165
|
else if (type === 'server_error') {
|
|
143
166
|
handleServerError(assistantMessage.id, setMessages);
|
|
144
167
|
break;
|
|
@@ -152,29 +175,54 @@ export default function useAsaResults(options) {
|
|
|
152
175
|
if (readerRef.current === reader) {
|
|
153
176
|
reader.cancel();
|
|
154
177
|
readerRef.current = null;
|
|
178
|
+
activeAssistantIdRef.current = null;
|
|
155
179
|
setIsStreaming(false);
|
|
156
180
|
isStreamingRef.current = false;
|
|
157
181
|
}
|
|
158
182
|
}
|
|
159
183
|
})();
|
|
160
184
|
}, [cioClient, domain, nextMessageId]);
|
|
161
|
-
useEffect(() => () =>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
useEffect(() => () => teardownStream(), [teardownStream]);
|
|
186
|
+
/**
|
|
187
|
+
* Cancels the in-flight request while keeping the conversation: the partial reply is
|
|
188
|
+
* settled as `done` (so the typing indicator stops and streamed text survives) and the
|
|
189
|
+
* thread id is kept, so the next message continues the same conversation rather than
|
|
190
|
+
* starting a new one. Use `clearHistory` to reset instead.
|
|
191
|
+
*
|
|
192
|
+
* A reply that had streamed nothing yet is dropped rather than settled — it would
|
|
193
|
+
* otherwise render as a blank bubble, and a cancelled turn would read as a glitch.
|
|
194
|
+
* "Nothing" means no text, no product groups and no follow-up refinement; any one of
|
|
195
|
+
* them is content worth keeping. The user's own message always stays.
|
|
196
|
+
*
|
|
197
|
+
* No beacon is sent: there is no "aborted" ASA event, and reporting the load as
|
|
198
|
+
* finished would be false. An aborted turn therefore leaves an
|
|
199
|
+
* `assistant_result_load_started` with no matching finished event.
|
|
200
|
+
*/
|
|
201
|
+
const abort = useCallback(() => {
|
|
202
|
+
if (!isStreamingRef.current)
|
|
203
|
+
return;
|
|
204
|
+
teardownStream();
|
|
205
|
+
setIsStreaming(false);
|
|
206
|
+
isStreamingRef.current = false;
|
|
207
|
+
const abortedId = activeAssistantIdRef.current;
|
|
208
|
+
activeAssistantIdRef.current = null;
|
|
209
|
+
if (abortedId) {
|
|
210
|
+
setMessages((prev) => prev.flatMap((msg) => {
|
|
211
|
+
if (msg.id !== abortedId)
|
|
212
|
+
return [msg];
|
|
213
|
+
if (!msg.text && !msg.groups?.length && !msg.refinement)
|
|
214
|
+
return [];
|
|
215
|
+
return [{ ...msg, status: 'done' }];
|
|
216
|
+
}));
|
|
166
217
|
}
|
|
167
|
-
}, []);
|
|
218
|
+
}, [teardownStream]);
|
|
168
219
|
const clearHistory = useCallback(() => {
|
|
169
|
-
|
|
170
|
-
if (readerRef.current) {
|
|
171
|
-
readerRef.current.cancel();
|
|
172
|
-
readerRef.current = null;
|
|
173
|
-
}
|
|
220
|
+
teardownStream();
|
|
174
221
|
threadIdRef.current = null;
|
|
222
|
+
activeAssistantIdRef.current = null;
|
|
175
223
|
setMessages([]);
|
|
176
224
|
setIsStreaming(false);
|
|
177
225
|
isStreamingRef.current = false;
|
|
178
|
-
}, []);
|
|
179
|
-
return { messages, sendMessage, isStreaming, clearHistory };
|
|
226
|
+
}, [teardownStream]);
|
|
227
|
+
return { messages, sendMessage, isStreaming, abort, clearHistory };
|
|
180
228
|
}
|
package/lib/mjs/styles.css
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
@import './components/Chat/ChatMessageList.css';
|
|
7
7
|
@import './components/Chat/UserMessage.css';
|
|
8
8
|
@import './components/Chat/AiMessage.css';
|
|
9
|
+
@import './components/Chat/FollowUpRefinement.css';
|
|
9
10
|
@import './components/Chat/TypingIndicator.css';
|
|
10
11
|
@import './components/Chat/ChatInput.css';
|
|
11
12
|
@import './components/ResultsBlock/ResultsBlock.css';
|
|
@@ -4,6 +4,7 @@ const defaultTranslations = {
|
|
|
4
4
|
'CioAsa.input.placeholder': 'Ask a question about this product',
|
|
5
5
|
'CioAsa.input.ariaLabel': 'Type your message',
|
|
6
6
|
'CioAsa.input.sendAriaLabel': 'Send message',
|
|
7
|
+
'CioAsa.input.stopAriaLabel': 'Stop response',
|
|
7
8
|
'CioAsa.welcome.title': 'Shopping Assistant',
|
|
8
9
|
'CioAsa.welcome.placeholder': 'Ask anything',
|
|
9
10
|
'CioAsa.welcome.sendButton': 'Chat',
|
|
@@ -16,6 +17,7 @@ const defaultTranslations = {
|
|
|
16
17
|
'CioAsa.results.viewMore': 'View more products',
|
|
17
18
|
'CioAsa.results.addToCart': 'Add to cart',
|
|
18
19
|
'CioAsa.results.saleBadge': 'Sale',
|
|
20
|
+
'CioAsa.refinement.ariaLabel': 'Refine your results',
|
|
19
21
|
'CioAsa.error.message': "I can't assist you with that request.",
|
|
20
22
|
};
|
|
21
23
|
export default function translate(key, translations) {
|
package/lib/mjs/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '1.
|
|
1
|
+
export default '1.6.0';
|
|
@@ -3,6 +3,13 @@ import { ChatComponentOverrides, ResultGroupMeta, Translations } from '../../typ
|
|
|
3
3
|
import { Product, NormalizeOptions } from '../../utils/productNormalizer';
|
|
4
4
|
import { AspectRatio } from '../ResultsBlock/ResultsBlock';
|
|
5
5
|
export interface ChatHandle {
|
|
6
|
+
/**
|
|
7
|
+
* Cancel the in-flight response, keeping the conversation. The partial reply is kept
|
|
8
|
+
* and the thread is preserved, so the next message continues where it left off.
|
|
9
|
+
* No-op when nothing is streaming.
|
|
10
|
+
*/
|
|
11
|
+
abort: () => void;
|
|
12
|
+
/** Cancel any active response, then reset the conversation and the thread. */
|
|
6
13
|
clearHistory: () => void;
|
|
7
14
|
}
|
|
8
15
|
interface ChatProps {
|
|
@@ -35,6 +42,12 @@ interface ChatProps {
|
|
|
35
42
|
translations?: Translations;
|
|
36
43
|
/** Seed the thread id (e.g. loaded from browser storage) to resume a prior conversation. Read once on mount. */
|
|
37
44
|
initialThreadId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the input's send button becomes a stop button while a reply streams.
|
|
47
|
+
* Defaults to `false`, so the packaged UI is unchanged unless you opt in. While it is
|
|
48
|
+
* off, cancelling is only reachable via `abort()` on the ref or your own input override.
|
|
49
|
+
*/
|
|
50
|
+
showStopButton?: boolean;
|
|
38
51
|
}
|
|
39
52
|
declare const Chat: React.ForwardRefExoticComponent<ChatProps & React.RefAttributes<ChatHandle>>;
|
|
40
53
|
export default Chat;
|
|
@@ -3,10 +3,20 @@ import { ChatInputRenderProps, ComponentOverrideProps, Translations } from '../.
|
|
|
3
3
|
interface ChatInputProps {
|
|
4
4
|
onSubmit: (text: string) => void;
|
|
5
5
|
isDisabled?: boolean;
|
|
6
|
+
/** A reply is streaming: the send button becomes a stop button. */
|
|
7
|
+
isStreaming?: boolean;
|
|
8
|
+
/** Cancel the in-flight reply. */
|
|
9
|
+
onAbort?: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the built-in stop button replaces send while streaming. Defaults to `false`:
|
|
12
|
+
* the send button stays (disabled). `onAbort` reaches the render props either way, so a
|
|
13
|
+
* consumer can render their own control without enabling this one.
|
|
14
|
+
*/
|
|
15
|
+
showStopButton?: boolean;
|
|
6
16
|
translations?: Translations;
|
|
7
17
|
componentOverrides?: ComponentOverrideProps<ChatInputRenderProps>;
|
|
8
18
|
}
|
|
9
|
-
declare function ChatInput({ onSubmit, isDisabled, translations, componentOverrides, }: ChatInputProps): React.JSX.Element;
|
|
19
|
+
declare function ChatInput({ onSubmit, isDisabled, isStreaming, onAbort, showStopButton, translations, componentOverrides, }: ChatInputProps): React.JSX.Element;
|
|
10
20
|
declare namespace ChatInput {
|
|
11
21
|
var displayName: string;
|
|
12
22
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { AiMessageOverrides, ChatMessage, ComponentOverrideProps, ResultGroupMeta, ResultsBlockOverrides, Translations, UserMessageRenderProps } from '../../types';
|
|
2
|
+
import { AiMessageOverrides, AssistantSubmitSource, ChatMessage, ComponentOverrideProps, ResultGroupMeta, ResultsBlockOverrides, Translations, UserMessageRenderProps } from '../../types';
|
|
3
3
|
import { Product, NormalizeOptions } from '../../utils/productNormalizer';
|
|
4
4
|
import { AspectRatio } from '../ResultsBlock/ResultsBlock';
|
|
5
5
|
interface ChatMessageListProps {
|
|
@@ -16,8 +16,12 @@ interface ChatMessageListProps {
|
|
|
16
16
|
userMessageOverrides?: ComponentOverrideProps<UserMessageRenderProps>;
|
|
17
17
|
resultsBlockOverrides?: ResultsBlockOverrides;
|
|
18
18
|
translations?: Translations;
|
|
19
|
+
/** Sends a refinement option as a follow-up message. Chips are hidden when omitted. */
|
|
20
|
+
onSend?: (text: string, source?: AssistantSubmitSource) => void;
|
|
21
|
+
/** Disables refinement chips while a response is streaming. */
|
|
22
|
+
isStreaming?: boolean;
|
|
19
23
|
}
|
|
20
|
-
declare function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, }: ChatMessageListProps): React.JSX.Element;
|
|
24
|
+
declare function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, onSend, isStreaming, }: ChatMessageListProps): React.JSX.Element;
|
|
21
25
|
declare namespace ChatMessageList {
|
|
22
26
|
var displayName: string;
|
|
23
27
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ComponentOverrideProps, FollowUpRefinement as FollowUpRefinementData, FollowUpRefinementRenderProps, Translations } from '../../types';
|
|
3
|
+
export interface FollowUpRefinementProps {
|
|
4
|
+
refinement: FollowUpRefinementData;
|
|
5
|
+
onOptionClick: (option: string) => void;
|
|
6
|
+
isDisabled?: boolean;
|
|
7
|
+
translations?: Translations;
|
|
8
|
+
componentOverrides?: ComponentOverrideProps<FollowUpRefinementRenderProps>;
|
|
9
|
+
}
|
|
10
|
+
declare function FollowUpRefinement({ refinement, onOptionClick, isDisabled, translations, componentOverrides, }: FollowUpRefinementProps): React.JSX.Element;
|
|
11
|
+
declare namespace FollowUpRefinement {
|
|
12
|
+
var displayName: string;
|
|
13
|
+
}
|
|
14
|
+
export default FollowUpRefinement;
|