@constructor-io/constructorio-ui-asa 1.5.0 → 1.6.1
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/dist/constructorio-ui-asa-bundled.js +26 -11
- package/lib/cjs/components/Chat/Chat.js +4 -3
- package/lib/cjs/components/Chat/ChatInput.css +11 -0
- package/lib/cjs/components/Chat/ChatInput.js +8 -3
- package/lib/cjs/components/CioAsa/CioAsa.js +23 -0
- package/lib/cjs/components/icons/StopIcon.js +9 -0
- package/lib/cjs/components/icons/index.js +3 -1
- package/lib/cjs/hooks/useAsaResults.js +58 -13
- package/lib/cjs/index.js +3 -1
- package/lib/cjs/utils/translate.js +1 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/Chat/Chat.js +4 -3
- package/lib/mjs/components/Chat/ChatInput.css +11 -0
- package/lib/mjs/components/Chat/ChatInput.js +9 -4
- package/lib/mjs/components/CioAsa/CioAsa.js +20 -0
- package/lib/mjs/components/icons/StopIcon.js +5 -0
- package/lib/mjs/components/icons/index.js +1 -0
- package/lib/mjs/hooks/useAsaResults.js +57 -13
- package/lib/mjs/index.js +1 -0
- package/lib/mjs/utils/translate.js +1 -0
- package/lib/mjs/version.js +1 -1
- package/lib/types/components/Chat/Chat.d.ts +14 -1
- package/lib/types/components/Chat/ChatInput.d.ts +11 -1
- package/lib/types/components/CioAsa/CioAsa.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/index.d.ts +3 -1
- package/lib/types/types.d.ts +15 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -26,8 +26,8 @@ function getAnnouncement(messages, translations) {
|
|
|
26
26
|
}
|
|
27
27
|
return '';
|
|
28
28
|
}
|
|
29
|
-
const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, }, ref) => {
|
|
30
|
-
const { messages, sendMessage, isStreaming, clearHistory } = (0, useAsaResults_1.default)({
|
|
29
|
+
const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, showStopButton = false, }, ref) => {
|
|
30
|
+
const { messages, sendMessage, isStreaming, abort, clearHistory } = (0, useAsaResults_1.default)({
|
|
31
31
|
initialThreadId,
|
|
32
32
|
});
|
|
33
33
|
const chatViewRef = (0, react_1.useRef)(null);
|
|
@@ -36,6 +36,7 @@ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions,
|
|
|
36
36
|
const isModal = typeof onClose === 'function';
|
|
37
37
|
const announcement = getAnnouncement(messages, translations);
|
|
38
38
|
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
39
|
+
abort,
|
|
39
40
|
clearHistory,
|
|
40
41
|
}));
|
|
41
42
|
(0, react_1.useEffect)(() => {
|
|
@@ -59,7 +60,7 @@ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions,
|
|
|
59
60
|
react_1.default.createElement(WelcomeScreen_1.default, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.welcomeScreen }))) : (react_1.default.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
|
|
60
61
|
react_1.default.createElement(ChatHeader_1.default, { onClose: onClose, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.header }),
|
|
61
62
|
react_1.default.createElement(ChatMessageList_1.default, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: (0, translate_1.default)('CioAsa.results.addToCart', translations), viewMoreText: (0, translate_1.default)('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.aiMessage, userMessageOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.userMessage, resultsBlockOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
|
|
62
|
-
react_1.default.createElement(ChatInput_1.default, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.input }))))));
|
|
63
|
+
react_1.default.createElement(ChatInput_1.default, { onSubmit: sendMessage, isDisabled: isStreaming, isStreaming: isStreaming, onAbort: abort, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.input }))))));
|
|
63
64
|
});
|
|
64
65
|
Chat.displayName = 'Chat';
|
|
65
66
|
exports.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
|
+
}
|
|
@@ -6,25 +6,30 @@ const constructorio_ui_components_1 = require("@constructor-io/constructorio-ui-
|
|
|
6
6
|
const icons_1 = require("../icons");
|
|
7
7
|
const translate_1 = tslib_1.__importDefault(require("../../utils/translate"));
|
|
8
8
|
const useMessageInput_1 = tslib_1.__importDefault(require("../../hooks/useMessageInput"));
|
|
9
|
-
function ChatInput({ onSubmit, isDisabled = false, translations, componentOverrides, }) {
|
|
9
|
+
function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort, showStopButton = false, translations, componentOverrides, }) {
|
|
10
10
|
const { value, setValue, handleSubmit, handleKeyDown } = (0, useMessageInput_1.default)({
|
|
11
11
|
onSend: onSubmit,
|
|
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
17
|
const renderProps = {
|
|
16
18
|
value,
|
|
17
19
|
onChange: setValue,
|
|
18
20
|
onSubmit: handleSubmit,
|
|
19
21
|
placeholder: (0, translate_1.default)('CioAsa.input.placeholder', translations),
|
|
20
22
|
isDisabled,
|
|
23
|
+
isStreaming,
|
|
24
|
+
onAbort: onAbort !== null && onAbort !== void 0 ? onAbort : (() => { }),
|
|
21
25
|
};
|
|
22
26
|
return (react_1.default.createElement(constructorio_ui_components_1.RenderPropsWrapper, { override: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.reactNode, props: renderProps },
|
|
23
27
|
react_1.default.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
|
|
24
28
|
react_1.default.createElement("div", { className: 'cio-asa-chat-input' },
|
|
25
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) }),
|
|
26
|
-
react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick:
|
|
27
|
-
react_1.default.createElement(icons_1.
|
|
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) },
|
|
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
|
+
react_1.default.createElement(icons_1.SendArrowIcon, null)))))));
|
|
28
33
|
}
|
|
29
34
|
exports.default = ChatInput;
|
|
30
35
|
ChatInput.displayName = 'ChatInput';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const react_1 = tslib_1.__importDefault(require("react"));
|
|
5
|
+
const CioAsaProvider_1 = tslib_1.__importDefault(require("../CioAsaProvider/CioAsaProvider"));
|
|
6
|
+
const Chat_1 = tslib_1.__importDefault(require("../Chat/Chat"));
|
|
7
|
+
/**
|
|
8
|
+
* The whole agent in one component: the provider and the chat UI composed together.
|
|
9
|
+
*
|
|
10
|
+
* Consumers that need to place `Chat` inside their own tree, or render several
|
|
11
|
+
* components against one client, should compose `CioAsaProvider` and `Chat`
|
|
12
|
+
* directly. This is the single-entry-point convenience wrapper, and it is what the
|
|
13
|
+
* standalone browser bundle (`src/bundled.jsx`) mounts.
|
|
14
|
+
*/
|
|
15
|
+
const CioAsa = react_1.default.forwardRef((props, ref) => {
|
|
16
|
+
const {
|
|
17
|
+
// Provider configuration — everything else belongs to Chat.
|
|
18
|
+
apiKey, cioClient, staticRequestConfigs, formatters, urlHelpers, callbacks, section } = props, chatProps = tslib_1.__rest(props, ["apiKey", "cioClient", "staticRequestConfigs", "formatters", "urlHelpers", "callbacks", "section"]);
|
|
19
|
+
return (react_1.default.createElement(CioAsaProvider_1.default, { apiKey: apiKey, cioClient: cioClient, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section },
|
|
20
|
+
react_1.default.createElement(Chat_1.default, Object.assign({}, chatProps, { ref: ref }))));
|
|
21
|
+
});
|
|
22
|
+
CioAsa.displayName = 'CioAsa';
|
|
23
|
+
exports.default = CioAsa;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const react_1 = tslib_1.__importDefault(require("react"));
|
|
5
|
+
function StopIcon() {
|
|
6
|
+
return (react_1.default.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' },
|
|
7
|
+
react_1.default.createElement("rect", { x: '0.5', y: '0.5', width: '9', height: '9', rx: '1.5', fill: '#0F1324', fillOpacity: '0.7' })));
|
|
8
|
+
}
|
|
9
|
+
exports.default = StopIcon;
|
|
@@ -3,13 +3,15 @@ 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.ArrowRightIcon = exports.SendPlaneIcon = exports.SendArrowIcon = exports.ChatBubbleLightIcon = exports.ChatBubbleDarkIcon = void 0;
|
|
6
|
+
exports.ArrowRightIcon = exports.SendPlaneIcon = exports.StopIcon = exports.SendArrowIcon = exports.ChatBubbleLightIcon = exports.ChatBubbleDarkIcon = void 0;
|
|
7
7
|
var ChatBubbleDarkIcon_1 = require("./ChatBubbleDarkIcon");
|
|
8
8
|
Object.defineProperty(exports, "ChatBubbleDarkIcon", { enumerable: true, get: function () { return __importDefault(ChatBubbleDarkIcon_1).default; } });
|
|
9
9
|
var ChatBubbleLightIcon_1 = require("./ChatBubbleLightIcon");
|
|
10
10
|
Object.defineProperty(exports, "ChatBubbleLightIcon", { enumerable: true, get: function () { return __importDefault(ChatBubbleLightIcon_1).default; } });
|
|
11
11
|
var SendArrowIcon_1 = require("./SendArrowIcon");
|
|
12
12
|
Object.defineProperty(exports, "SendArrowIcon", { enumerable: true, get: function () { return __importDefault(SendArrowIcon_1).default; } });
|
|
13
|
+
var StopIcon_1 = require("./StopIcon");
|
|
14
|
+
Object.defineProperty(exports, "StopIcon", { enumerable: true, get: function () { return __importDefault(StopIcon_1).default; } });
|
|
13
15
|
var SendPlaneIcon_1 = require("./SendPlaneIcon");
|
|
14
16
|
Object.defineProperty(exports, "SendPlaneIcon", { enumerable: true, get: function () { return __importDefault(SendPlaneIcon_1).default; } });
|
|
15
17
|
var ArrowRightIcon_1 = require("./ArrowRightIcon");
|
|
@@ -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;
|
|
@@ -159,30 +178,56 @@ function useAsaResults(options) {
|
|
|
159
178
|
if (readerRef.current === reader) {
|
|
160
179
|
reader.cancel();
|
|
161
180
|
readerRef.current = null;
|
|
181
|
+
activeAssistantIdRef.current = null;
|
|
162
182
|
setIsStreaming(false);
|
|
163
183
|
isStreamingRef.current = false;
|
|
164
184
|
}
|
|
165
185
|
}
|
|
166
186
|
}))();
|
|
167
187
|
}, [cioClient, domain, nextMessageId]);
|
|
168
|
-
(0, react_1.useEffect)(() => () =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}));
|
|
173
221
|
}
|
|
174
|
-
}, []);
|
|
222
|
+
}, [teardownStream]);
|
|
175
223
|
const clearHistory = (0, react_1.useCallback)(() => {
|
|
176
|
-
|
|
177
|
-
if (readerRef.current) {
|
|
178
|
-
readerRef.current.cancel();
|
|
179
|
-
readerRef.current = null;
|
|
180
|
-
}
|
|
224
|
+
teardownStream();
|
|
181
225
|
threadIdRef.current = null;
|
|
226
|
+
activeAssistantIdRef.current = null;
|
|
182
227
|
setMessages([]);
|
|
183
228
|
setIsStreaming(false);
|
|
184
229
|
isStreamingRef.current = false;
|
|
185
|
-
}, []);
|
|
186
|
-
return { messages, sendMessage, isStreaming, clearHistory };
|
|
230
|
+
}, [teardownStream]);
|
|
231
|
+
return { messages, sendMessage, isStreaming, abort, clearHistory };
|
|
187
232
|
}
|
|
188
233
|
exports.default = useAsaResults;
|
package/lib/cjs/index.js
CHANGED
|
@@ -3,8 +3,10 @@ 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.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = void 0;
|
|
6
|
+
exports.ConstructorIOClient = exports.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = exports.CioAsa = void 0;
|
|
7
7
|
// Components
|
|
8
|
+
var CioAsa_1 = require("./components/CioAsa/CioAsa");
|
|
9
|
+
Object.defineProperty(exports, "CioAsa", { enumerable: true, get: function () { return __importDefault(CioAsa_1).default; } });
|
|
8
10
|
var CioAsaProvider_1 = require("./components/CioAsaProvider/CioAsaProvider");
|
|
9
11
|
Object.defineProperty(exports, "CioAsaProvider", { enumerable: true, get: function () { return __importDefault(CioAsaProvider_1).default; } });
|
|
10
12
|
var Chat_1 = require("./components/Chat/Chat");
|
|
@@ -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',
|
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(() => {
|
|
@@ -55,7 +56,7 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
|
|
|
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
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 }),
|
|
58
|
-
React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides?.input }))))));
|
|
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';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import CioAsaProvider from '../CioAsaProvider/CioAsaProvider';
|
|
3
|
+
import Chat from '../Chat/Chat';
|
|
4
|
+
/**
|
|
5
|
+
* The whole agent in one component: the provider and the chat UI composed together.
|
|
6
|
+
*
|
|
7
|
+
* Consumers that need to place `Chat` inside their own tree, or render several
|
|
8
|
+
* components against one client, should compose `CioAsaProvider` and `Chat`
|
|
9
|
+
* directly. This is the single-entry-point convenience wrapper, and it is what the
|
|
10
|
+
* standalone browser bundle (`src/bundled.jsx`) mounts.
|
|
11
|
+
*/
|
|
12
|
+
const CioAsa = React.forwardRef((props, ref) => {
|
|
13
|
+
const {
|
|
14
|
+
// Provider configuration — everything else belongs to Chat.
|
|
15
|
+
apiKey, cioClient, staticRequestConfigs, formatters, urlHelpers, callbacks, section, ...chatProps } = props;
|
|
16
|
+
return (React.createElement(CioAsaProvider, { apiKey: apiKey, cioClient: cioClient, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section },
|
|
17
|
+
React.createElement(Chat, { ...chatProps, ref: ref })));
|
|
18
|
+
});
|
|
19
|
+
CioAsa.displayName = 'CioAsa';
|
|
20
|
+
export default CioAsa;
|
|
@@ -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';
|
|
@@ -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;
|
|
@@ -156,29 +175,54 @@ export default function useAsaResults(options) {
|
|
|
156
175
|
if (readerRef.current === reader) {
|
|
157
176
|
reader.cancel();
|
|
158
177
|
readerRef.current = null;
|
|
178
|
+
activeAssistantIdRef.current = null;
|
|
159
179
|
setIsStreaming(false);
|
|
160
180
|
isStreamingRef.current = false;
|
|
161
181
|
}
|
|
162
182
|
}
|
|
163
183
|
})();
|
|
164
184
|
}, [cioClient, domain, nextMessageId]);
|
|
165
|
-
useEffect(() => () =>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
}));
|
|
170
217
|
}
|
|
171
|
-
}, []);
|
|
218
|
+
}, [teardownStream]);
|
|
172
219
|
const clearHistory = useCallback(() => {
|
|
173
|
-
|
|
174
|
-
if (readerRef.current) {
|
|
175
|
-
readerRef.current.cancel();
|
|
176
|
-
readerRef.current = null;
|
|
177
|
-
}
|
|
220
|
+
teardownStream();
|
|
178
221
|
threadIdRef.current = null;
|
|
222
|
+
activeAssistantIdRef.current = null;
|
|
179
223
|
setMessages([]);
|
|
180
224
|
setIsStreaming(false);
|
|
181
225
|
isStreamingRef.current = false;
|
|
182
|
-
}, []);
|
|
183
|
-
return { messages, sendMessage, isStreaming, clearHistory };
|
|
226
|
+
}, [teardownStream]);
|
|
227
|
+
return { messages, sendMessage, isStreaming, abort, clearHistory };
|
|
184
228
|
}
|
package/lib/mjs/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Components
|
|
2
|
+
export { default as CioAsa } from './components/CioAsa/CioAsa';
|
|
2
3
|
export { default as CioAsaProvider } from './components/CioAsaProvider/CioAsaProvider';
|
|
3
4
|
export { default as Chat } from './components/Chat/Chat';
|
|
4
5
|
export { default as ResultsBlock } from './components/ResultsBlock/ResultsBlock';
|
|
@@ -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',
|
package/lib/mjs/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '1.
|
|
1
|
+
export default '1.6.1';
|
|
@@ -3,9 +3,16 @@ 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
|
-
interface ChatProps {
|
|
15
|
+
export interface ChatProps {
|
|
9
16
|
/** Called when the close button (✕) is clicked. The consumer controls visibility. */
|
|
10
17
|
onClose?: () => void;
|
|
11
18
|
/** Additional CSS class name for the root container */
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ChatHandle, ChatProps } from '../Chat/Chat';
|
|
3
|
+
import { CioAsaProviderProps } from '../../types';
|
|
4
|
+
export type CioAsaProps = CioAsaProviderProps & ChatProps;
|
|
5
|
+
/**
|
|
6
|
+
* The whole agent in one component: the provider and the chat UI composed together.
|
|
7
|
+
*
|
|
8
|
+
* Consumers that need to place `Chat` inside their own tree, or render several
|
|
9
|
+
* components against one client, should compose `CioAsaProvider` and `Chat`
|
|
10
|
+
* directly. This is the single-entry-point convenience wrapper, and it is what the
|
|
11
|
+
* standalone browser bundle (`src/bundled.jsx`) mounts.
|
|
12
|
+
*/
|
|
13
|
+
declare const CioAsa: React.ForwardRefExoticComponent<CioAsaProviderProps & ChatProps & React.RefAttributes<ChatHandle>>;
|
|
14
|
+
export default CioAsa;
|
|
@@ -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';
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
export { default as CioAsa } from './components/CioAsa/CioAsa';
|
|
2
|
+
export type { CioAsaProps } from './components/CioAsa/CioAsa';
|
|
1
3
|
export { default as CioAsaProvider } from './components/CioAsaProvider/CioAsaProvider';
|
|
2
4
|
export { default as Chat } from './components/Chat/Chat';
|
|
3
|
-
export type { ChatHandle } from './components/Chat/Chat';
|
|
5
|
+
export type { ChatHandle, ChatProps } from './components/Chat/Chat';
|
|
4
6
|
export { default as ResultsBlock } from './components/ResultsBlock/ResultsBlock';
|
|
5
7
|
export type { AspectRatio } from './components/ResultsBlock/ResultsBlock';
|
|
6
8
|
export { default as Button } from './components/Button/Button';
|