@constructor-io/constructorio-ui-asa 1.8.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 +79 -21
- package/lib/cjs/components/Button/Button.js +22 -3
- 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 +18 -4
- 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 +54 -26
- package/lib/cjs/hooks/useAsaTracking.js +31 -16
- 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/Button/Button.js +26 -3
- 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 +18 -4
- 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 +53 -24
- package/lib/mjs/hooks/useAsaTracking.js +40 -16
- 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/components/Button/Button.d.ts +3 -2
- package/lib/types/components/Chat/Chat.d.ts +13 -1
- package/lib/types/hooks/persistence/backgroundTurns.d.ts +6 -0
- package/lib/types/hooks/persistence/useChatPersistence.d.ts +4 -0
- package/lib/types/hooks/useAsaTracking.d.ts +4 -2
- package/lib/types/index.d.ts +3 -2
- package/lib/types/types.d.ts +57 -8
- 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 +6 -2
|
@@ -5,6 +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, settleCancelledReply } from '../utils/chatThreads';
|
|
8
9
|
import useLatest from './useLatest';
|
|
9
10
|
export default function useAsaResults(options) {
|
|
10
11
|
const contextValue = useCioAsaContext();
|
|
@@ -38,6 +39,18 @@ export default function useAsaResults(options) {
|
|
|
38
39
|
turnRef.current?.cancel();
|
|
39
40
|
turnRef.current = null;
|
|
40
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]);
|
|
41
54
|
const store = useChatPersistence({
|
|
42
55
|
store: persistence,
|
|
43
56
|
scope: persistenceScope,
|
|
@@ -50,15 +63,16 @@ export default function useAsaResults(options) {
|
|
|
50
63
|
setIsStreaming,
|
|
51
64
|
turnRef,
|
|
52
65
|
cancelStream,
|
|
66
|
+
abortTurn: abortOwnTurn,
|
|
53
67
|
});
|
|
54
|
-
const { beginTurn, screenOwner } = store;
|
|
68
|
+
const { beginTurn, screenOwner, abortForeign } = store;
|
|
55
69
|
useEffect(() => cancelStream, [cancelStream]);
|
|
56
|
-
const
|
|
70
|
+
const sendToCurrentThread = useCallback((text, source) => {
|
|
57
71
|
const intent = text.trim();
|
|
58
72
|
if (!intent || session.isStreaming || session.foreignInFlight)
|
|
59
73
|
return;
|
|
60
74
|
beginTurn();
|
|
61
|
-
trackingRef.current.trackSubmit(intent);
|
|
75
|
+
trackingRef.current.trackSubmit(intent, source);
|
|
62
76
|
callbacksRef.current?.onAssistantSubmit?.({ intent, source });
|
|
63
77
|
const userMessage = {
|
|
64
78
|
id: nextMessageId(session),
|
|
@@ -108,6 +122,32 @@ export default function useAsaResults(options) {
|
|
|
108
122
|
callbacksRef,
|
|
109
123
|
staticRequestConfigsRef,
|
|
110
124
|
]);
|
|
125
|
+
const { newThread, switchThread } = store;
|
|
126
|
+
/**
|
|
127
|
+
* Sends `text`. With `threadId`, first moves to that conversation, loading it from storage
|
|
128
|
+
* when persistence has it and otherwise continuing it on the server with an empty screen.
|
|
129
|
+
*/
|
|
130
|
+
const sendMessage = useCallback((text, source = 'input', { threadId } = {}) => {
|
|
131
|
+
if (!threadId ||
|
|
132
|
+
threadId === session.serverThreadId ||
|
|
133
|
+
threadId === session.storageThreadId) {
|
|
134
|
+
sendToCurrentThread(text, source);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
(async () => {
|
|
138
|
+
if (persistence)
|
|
139
|
+
await switchThread(threadId);
|
|
140
|
+
else
|
|
141
|
+
newThread();
|
|
142
|
+
// A turn that started meanwhile (or one still streaming in the background) owns the screen.
|
|
143
|
+
if (session.isStreaming || session.foreignInFlight)
|
|
144
|
+
return;
|
|
145
|
+
if (!session.serverThreadId && !isLocalThreadId(threadId)) {
|
|
146
|
+
session.serverThreadId = threadId;
|
|
147
|
+
}
|
|
148
|
+
sendToCurrentThread(text, source);
|
|
149
|
+
})();
|
|
150
|
+
}, [session, persistence, newThread, switchThread, sendToCurrentThread]);
|
|
111
151
|
/**
|
|
112
152
|
* Cancels the in-flight request while keeping the conversation: the partial reply is
|
|
113
153
|
* settled as `done` (so the typing indicator stops and streamed text survives) and the
|
|
@@ -122,37 +162,26 @@ export default function useAsaResults(options) {
|
|
|
122
162
|
* No beacon is sent: there is no "aborted" ASA event, and reporting the load as
|
|
123
163
|
* finished would be false. An aborted turn therefore leaves an
|
|
124
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.
|
|
125
167
|
*/
|
|
126
168
|
const abort = useCallback(() => {
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
setIsStreaming(false);
|
|
133
|
-
if (abortedId) {
|
|
134
|
-
setMessages((prev) => prev.flatMap((msg) => {
|
|
135
|
-
if (msg.id !== abortedId)
|
|
136
|
-
return [msg];
|
|
137
|
-
if (!msg.text && !msg.groups?.length && !msg.refinement)
|
|
138
|
-
return [];
|
|
139
|
-
return [{ ...msg, status: 'done' }];
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
|
-
// With persistence on, the cancelled turn is stored as it stands.
|
|
143
|
-
session.dirty = true;
|
|
144
|
-
}, [session, cancelStream]);
|
|
169
|
+
if (session.isStreaming)
|
|
170
|
+
abortOwnTurn();
|
|
171
|
+
else
|
|
172
|
+
abortForeign();
|
|
173
|
+
}, [session, abortOwnTurn, abortForeign]);
|
|
145
174
|
return {
|
|
146
175
|
messages,
|
|
147
176
|
sendMessage,
|
|
148
177
|
isStreaming: isStreaming || store.foreignInFlight,
|
|
149
|
-
canAbort: isStreaming,
|
|
178
|
+
canAbort: isStreaming || store.canAbortForeign,
|
|
150
179
|
abort,
|
|
151
180
|
clearHistory: store.clearHistory,
|
|
152
181
|
isHydrating: store.isHydrating,
|
|
153
182
|
threads: store.threads,
|
|
154
183
|
activeThreadId: store.activeThreadId,
|
|
155
|
-
newThread
|
|
156
|
-
switchThread
|
|
184
|
+
newThread,
|
|
185
|
+
switchThread,
|
|
157
186
|
};
|
|
158
187
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { useCallback, useMemo } from 'react';
|
|
2
|
+
/** Beacon `source` values, aligned with the ones the behavioral-actions API documents. */
|
|
3
|
+
const BEACON_SUBMIT_SOURCES = {
|
|
4
|
+
input: 'input',
|
|
5
|
+
suggestion: 'suggestion',
|
|
6
|
+
refinement: 'follow_up',
|
|
7
|
+
// A prompt the host page sent, typically a suggested question clicked outside the chat.
|
|
8
|
+
external: 'suggestion',
|
|
9
|
+
};
|
|
2
10
|
const NOOP_TRACKING = {
|
|
11
|
+
trackAgentButtonClick: () => { },
|
|
3
12
|
trackSubmit: () => { },
|
|
4
13
|
trackResultLoadStarted: () => { },
|
|
5
14
|
trackResultLoadFinished: () => { },
|
|
@@ -13,32 +22,45 @@ const NOOP_TRACKING = {
|
|
|
13
22
|
* Mirrors the tracking-hook pattern used by constructorio-ui-pia.
|
|
14
23
|
*/
|
|
15
24
|
export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
16
|
-
const assistant = tracker;
|
|
17
25
|
const base = useMemo(() => ({
|
|
18
26
|
...(section && { section }),
|
|
19
27
|
...(threadId && { threadId }),
|
|
20
28
|
}), [section, threadId]);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const trackAgentButtonClick = useCallback(({ mode, agentDomain, positionOnPage, pageType, instanceId }) => {
|
|
30
|
+
tracker?.trackAgentButtonClick?.({
|
|
31
|
+
mode,
|
|
32
|
+
agentDomain,
|
|
33
|
+
...(positionOnPage && { positionOnPage }),
|
|
34
|
+
...(pageType && { pageType }),
|
|
35
|
+
...(instanceId && { instanceId }),
|
|
36
|
+
...(section && { section }),
|
|
37
|
+
});
|
|
38
|
+
}, [tracker, section]);
|
|
39
|
+
const trackSubmit = useCallback((intent, source) => {
|
|
40
|
+
tracker?.trackAssistantSubmit({
|
|
41
|
+
intent,
|
|
42
|
+
...(source && { source: BEACON_SUBMIT_SOURCES[source] }),
|
|
43
|
+
...base,
|
|
44
|
+
});
|
|
45
|
+
}, [tracker, base]);
|
|
24
46
|
const trackResultLoadStarted = useCallback(({ intent, intentResultId }) => {
|
|
25
|
-
|
|
47
|
+
tracker?.trackAssistantResultLoadStarted({
|
|
26
48
|
intent,
|
|
27
49
|
...(intentResultId && { intentResultId }),
|
|
28
50
|
...base,
|
|
29
51
|
});
|
|
30
|
-
}, [
|
|
52
|
+
}, [tracker, base]);
|
|
31
53
|
const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId, threadId: turnThreadId, }) => {
|
|
32
|
-
|
|
54
|
+
tracker?.trackAssistantResultLoadFinished({
|
|
33
55
|
intent,
|
|
34
56
|
searchResultCount,
|
|
35
57
|
...(intentResultId && { intentResultId }),
|
|
36
58
|
...base,
|
|
37
59
|
...(turnThreadId && { threadId: turnThreadId }),
|
|
38
60
|
});
|
|
39
|
-
}, [
|
|
61
|
+
}, [tracker, base]);
|
|
40
62
|
const trackResultClick = useCallback(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
|
|
41
|
-
|
|
63
|
+
tracker?.trackAssistantResultClick({
|
|
42
64
|
intent,
|
|
43
65
|
searchResultId,
|
|
44
66
|
...(intentResultId && { intentResultId }),
|
|
@@ -47,9 +69,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
47
69
|
...(variationId && { variationId }),
|
|
48
70
|
...base,
|
|
49
71
|
});
|
|
50
|
-
}, [
|
|
72
|
+
}, [tracker, base]);
|
|
51
73
|
const trackResultView = useCallback(({ intent, searchResultId, numResultsViewed, intentResultId, items }) => {
|
|
52
|
-
|
|
74
|
+
tracker?.trackAssistantResultView({
|
|
53
75
|
intent,
|
|
54
76
|
searchResultId,
|
|
55
77
|
numResultsViewed,
|
|
@@ -57,9 +79,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
57
79
|
...(items && items.length > 0 && { items }),
|
|
58
80
|
...base,
|
|
59
81
|
});
|
|
60
|
-
}, [
|
|
82
|
+
}, [tracker, base]);
|
|
61
83
|
const trackSearchSubmit = useCallback(({ intent, searchTerm, userInput, searchResultId, intentResultId, groupId, }) => {
|
|
62
|
-
|
|
84
|
+
tracker?.trackAssistantSearchSubmit({
|
|
63
85
|
intent,
|
|
64
86
|
searchTerm,
|
|
65
87
|
userInput,
|
|
@@ -68,11 +90,12 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
68
90
|
...(groupId && { groupId }),
|
|
69
91
|
...base,
|
|
70
92
|
});
|
|
71
|
-
}, [
|
|
93
|
+
}, [tracker, base]);
|
|
72
94
|
return useMemo(() => {
|
|
73
|
-
if (!
|
|
95
|
+
if (!tracker)
|
|
74
96
|
return NOOP_TRACKING;
|
|
75
97
|
return {
|
|
98
|
+
trackAgentButtonClick,
|
|
76
99
|
trackSubmit,
|
|
77
100
|
trackResultLoadStarted,
|
|
78
101
|
trackResultLoadFinished,
|
|
@@ -81,7 +104,8 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
81
104
|
trackSearchSubmit,
|
|
82
105
|
};
|
|
83
106
|
}, [
|
|
84
|
-
|
|
107
|
+
tracker,
|
|
108
|
+
trackAgentButtonClick,
|
|
85
109
|
trackSubmit,
|
|
86
110
|
trackResultLoadStarted,
|
|
87
111
|
trackResultLoadFinished,
|
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
|
|
@@ -64,21 +64,31 @@ const emptyStored = () => ({
|
|
|
64
64
|
threads: {},
|
|
65
65
|
deleted: {},
|
|
66
66
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return emptyStored();
|
|
67
|
+
/** The record `raw` holds, or `null` when it is not one of this version. */
|
|
68
|
+
function readStored(raw) {
|
|
70
69
|
try {
|
|
71
70
|
const parsed = JSON.parse(raw);
|
|
72
71
|
if (parsed?.version !== PERSISTED_CHAT_VERSION || !parsed.threads)
|
|
73
|
-
return
|
|
72
|
+
return null;
|
|
74
73
|
const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => isPersistedChat(chat)));
|
|
75
74
|
const deleted = Object.fromEntries(Object.entries(parsed.deleted ?? {}).filter(([, at]) => typeof at === 'number'));
|
|
76
75
|
return { version: PERSISTED_CHAT_VERSION, threads, deleted };
|
|
77
76
|
}
|
|
78
77
|
catch {
|
|
79
|
-
return
|
|
78
|
+
return null;
|
|
80
79
|
}
|
|
81
80
|
}
|
|
81
|
+
function parseStored(raw) {
|
|
82
|
+
return (raw && readStored(raw)) || emptyStored();
|
|
83
|
+
}
|
|
84
|
+
function dropExpired(data, cutoff) {
|
|
85
|
+
return {
|
|
86
|
+
version: PERSISTED_CHAT_VERSION,
|
|
87
|
+
threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
|
|
88
|
+
deleted: Object.fromEntries(Object.entries(data.deleted ?? {}).filter(([, at]) => at >= cutoff)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const entryCount = (data) => Object.keys(data.threads).length + Object.keys(data.deleted ?? {}).length;
|
|
82
92
|
function readItem(storage, key) {
|
|
83
93
|
try {
|
|
84
94
|
return storage.getItem(key);
|
|
@@ -87,6 +97,42 @@ function readItem(storage, key) {
|
|
|
87
97
|
return null;
|
|
88
98
|
}
|
|
89
99
|
}
|
|
100
|
+
/** How often a write is redone when another tab changed the key between its read and its write. */
|
|
101
|
+
const MAX_COMMIT_ATTEMPTS = 3;
|
|
102
|
+
/** Drops expired threads and tombstones under every namespace of `key`, other shoppers' included. */
|
|
103
|
+
async function sweepExpired(storage, key, ttlMs) {
|
|
104
|
+
const prefix = storageKeyFor(key);
|
|
105
|
+
let keys;
|
|
106
|
+
try {
|
|
107
|
+
keys = Array.from({ length: storage.length }, (_, i) => storage.key(i)).filter((k) => k === prefix || Boolean(k?.startsWith(`${prefix}:`)));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await Promise.all(keys.map((k) => withStorageLock(k, () => {
|
|
113
|
+
for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
|
|
114
|
+
const raw = readItem(storage, k);
|
|
115
|
+
const stored = raw === null ? null : readStored(raw);
|
|
116
|
+
if (!stored)
|
|
117
|
+
return;
|
|
118
|
+
const kept = dropExpired(stored, Date.now() - ttlMs);
|
|
119
|
+
if (entryCount(kept) === entryCount(stored))
|
|
120
|
+
return;
|
|
121
|
+
if (readItem(storage, k) === raw) {
|
|
122
|
+
try {
|
|
123
|
+
if (entryCount(kept) === 0)
|
|
124
|
+
storage.removeItem(k);
|
|
125
|
+
else
|
|
126
|
+
storage.setItem(k, JSON.stringify(kept));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* storage unavailable */
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})));
|
|
135
|
+
}
|
|
90
136
|
/**
|
|
91
137
|
* Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
|
|
92
138
|
* follow. Resolves once the deletion is recorded.
|
|
@@ -132,6 +178,23 @@ export async function clearPersistedConversations(options) {
|
|
|
132
178
|
/* a non-Storage `storage` option cannot be attached to the event */
|
|
133
179
|
}
|
|
134
180
|
}
|
|
181
|
+
/** The channel abort requests travel on between tabs, or `null` where none can be opened. */
|
|
182
|
+
function openAbortChannel(name, onRequest) {
|
|
183
|
+
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined')
|
|
184
|
+
return null;
|
|
185
|
+
try {
|
|
186
|
+
const channel = new BroadcastChannel(name);
|
|
187
|
+
channel.onmessage = ({ data }) => {
|
|
188
|
+
if (typeof data?.threadId !== 'string' || typeof data?.messageId !== 'string')
|
|
189
|
+
return;
|
|
190
|
+
onRequest({ threadId: data.threadId, messageId: data.messageId });
|
|
191
|
+
};
|
|
192
|
+
return channel;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
135
198
|
/** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
|
|
136
199
|
export function createLocalStoragePersistence(options = {}) {
|
|
137
200
|
const { key = DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
|
|
@@ -144,15 +207,20 @@ export function createLocalStoragePersistence(options = {}) {
|
|
|
144
207
|
return emptyStored();
|
|
145
208
|
if (lastParsed?.raw !== raw)
|
|
146
209
|
lastParsed = { raw, data: parseStored(raw) };
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
210
|
+
return dropExpired(lastParsed.data, Date.now() - ttlMs);
|
|
211
|
+
};
|
|
212
|
+
// Other shoppers' records expire only here: their own store may never run again on this device.
|
|
213
|
+
let swept = false;
|
|
214
|
+
const sweepOnce = () => {
|
|
215
|
+
if (swept)
|
|
216
|
+
return;
|
|
217
|
+
swept = true;
|
|
218
|
+
const local = resolveStorage(storageOption, 'local');
|
|
219
|
+
if (local)
|
|
220
|
+
sweepExpired(local, key, ttlMs).catch(() => { });
|
|
154
221
|
};
|
|
155
222
|
const read = () => {
|
|
223
|
+
sweepOnce();
|
|
156
224
|
const storage = resolveStorage(storageOption, storageArea);
|
|
157
225
|
return parse(storage ? readRaw(storage) : null);
|
|
158
226
|
};
|
|
@@ -174,7 +242,7 @@ export function createLocalStoragePersistence(options = {}) {
|
|
|
174
242
|
/* ignore */
|
|
175
243
|
}
|
|
176
244
|
};
|
|
177
|
-
const isEmpty = (data) =>
|
|
245
|
+
const isEmpty = (data) => entryCount(data) === 0;
|
|
178
246
|
// Retries without tombstones, oldest first: the last thing to shed, see `write`.
|
|
179
247
|
const tryWriteShedding = (storage, data) => {
|
|
180
248
|
if (tryWrite(storage, data))
|
|
@@ -238,8 +306,8 @@ export function createLocalStoragePersistence(options = {}) {
|
|
|
238
306
|
tryWriteShedding(storage, others);
|
|
239
307
|
};
|
|
240
308
|
// Redo the merge if another tab wrote between our read and this write.
|
|
241
|
-
const MAX_COMMIT_ATTEMPTS = 3;
|
|
242
309
|
const transact = (mutate, priorityThreadId) => {
|
|
310
|
+
sweepOnce();
|
|
243
311
|
const storage = resolveStorage(storageOption, storageArea);
|
|
244
312
|
if (!storage)
|
|
245
313
|
return;
|
|
@@ -297,6 +365,29 @@ export function createLocalStoragePersistence(options = {}) {
|
|
|
297
365
|
}
|
|
298
366
|
return next;
|
|
299
367
|
};
|
|
368
|
+
const abortListeners = new Set();
|
|
369
|
+
// Only `localStorage` is shared between tabs; a guest's `sessionStorage` thread streams in this tab alone.
|
|
370
|
+
const channel = storageArea === 'local'
|
|
371
|
+
? openAbortChannel(`${storageKey}:abort`, (request) => abortListeners.forEach((listener) => listener(request)))
|
|
372
|
+
: null;
|
|
373
|
+
const abortMethods = channel
|
|
374
|
+
? {
|
|
375
|
+
requestAbort(threadId, messageId) {
|
|
376
|
+
try {
|
|
377
|
+
channel.postMessage({ threadId, messageId });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
/* channel closed */
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
subscribeAbort(listener) {
|
|
384
|
+
abortListeners.add(listener);
|
|
385
|
+
return () => {
|
|
386
|
+
abortListeners.delete(listener);
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
: {};
|
|
300
391
|
const retire = (data, staleId) => {
|
|
301
392
|
const { [staleId]: retired, ...threads } = data.threads;
|
|
302
393
|
return { ...data, threads, deleted: { ...data.deleted, [staleId]: Date.now() } };
|
|
@@ -352,6 +443,7 @@ export function createLocalStoragePersistence(options = {}) {
|
|
|
352
443
|
window.addEventListener('storage', onStorage);
|
|
353
444
|
return () => window.removeEventListener('storage', onStorage);
|
|
354
445
|
},
|
|
446
|
+
...abortMethods,
|
|
355
447
|
};
|
|
356
448
|
}
|
|
357
449
|
const sharedStores = new Map();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
import { marked } from 'marked';
|
|
3
|
+
// The text comes from an LLM a shopper can steer, so only the tags `marked` outputs are kept.
|
|
4
|
+
// Images, media and SVG would load a URL with no click and could leak data; form controls and
|
|
5
|
+
// `style` could fake a form.
|
|
6
|
+
const purifyConfig = {
|
|
7
|
+
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(' '),
|
|
8
|
+
ALLOWED_ATTR: ['href', 'tabindex', 'align', 'start'],
|
|
9
|
+
};
|
|
10
|
+
// Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
|
|
11
|
+
// links to this site, its subdomains, email and phone stay clickable.
|
|
12
|
+
function isOnThisSite(href) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(href, window.location.href);
|
|
15
|
+
if (url.protocol === 'mailto:' || url.protocol === 'tel:')
|
|
16
|
+
return true;
|
|
17
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
18
|
+
return false;
|
|
19
|
+
const site = window.location.hostname.replace(/^www\./, '');
|
|
20
|
+
return url.hostname === site || url.hostname.endsWith(`.${site}`);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Turns the agent's markdown into sanitized HTML: paragraphs, bold, links, lists and tables.
|
|
28
|
+
* Links to other sites become plain text. Browser only: without a DOM to sanitize with (server
|
|
29
|
+
* rendering) it returns `''`, so render the plain text instead.
|
|
30
|
+
*/
|
|
31
|
+
export function renderMarkdown(markdown) {
|
|
32
|
+
if (!markdown || !DOMPurify.isSupported)
|
|
33
|
+
return '';
|
|
34
|
+
const html = marked.parse(markdown, { async: false, breaks: true })
|
|
35
|
+
// A wide table scrolls (AiMessage.css), so keyboard users need to be able to focus it.
|
|
36
|
+
.replace(/<table>/g, '<table tabindex="0">');
|
|
37
|
+
const doc = new DOMParser().parseFromString(DOMPurify.sanitize(html, purifyConfig), 'text/html');
|
|
38
|
+
doc.querySelectorAll('a[href]').forEach((link) => {
|
|
39
|
+
if (!isOnThisSite(link.getAttribute('href')))
|
|
40
|
+
link.replaceWith(...Array.from(link.childNodes));
|
|
41
|
+
});
|
|
42
|
+
return doc.body.innerHTML;
|
|
43
|
+
}
|
|
44
|
+
/** The agent's markdown as plain text, one space between blocks and table cells, for screen readers. */
|
|
45
|
+
export function markdownToText(markdown) {
|
|
46
|
+
const html = renderMarkdown(markdown);
|
|
47
|
+
if (!html)
|
|
48
|
+
return markdown;
|
|
49
|
+
// An inert document: nothing in it loads or runs.
|
|
50
|
+
const { body } = new DOMParser().parseFromString(html, 'text/html');
|
|
51
|
+
body.querySelectorAll('p, li, th, td, br').forEach((node) => node.append(' '));
|
|
52
|
+
return (body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
}
|
package/lib/mjs/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '1.
|
|
1
|
+
export default '1.10.0';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
|
|
2
|
+
import { AgentButtonClickPlacement } from '../../types';
|
|
3
|
+
export interface ButtonProps extends AgentButtonClickPlacement {
|
|
3
4
|
/** Color scheme: dark (for light backgrounds) or light (for dark backgrounds) */
|
|
4
5
|
theme?: 'dark' | 'light';
|
|
5
6
|
/** Button size: sm (small) or lg (large) */
|
|
@@ -9,4 +10,4 @@ export interface ButtonProps {
|
|
|
9
10
|
/** Button label text */
|
|
10
11
|
label?: string;
|
|
11
12
|
}
|
|
12
|
-
export default function Button({ theme, size, onClick, label, }: ButtonProps): React.JSX.Element;
|
|
13
|
+
export default function Button({ theme, size, onClick, label, positionOnPage, pageType, instanceId, }: ButtonProps): React.JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { ChatComponentOverrides, ResultGroupMeta, ThreadSummary, Translations } from '../../types';
|
|
2
|
+
import { ChatComponentOverrides, ResultGroupMeta, SendMessageOptions, ThreadSummary, Translations } from '../../types';
|
|
3
3
|
import { Product, NormalizeOptions } from '../../utils/productNormalizer';
|
|
4
4
|
import { AspectRatio } from '../ResultsBlock/ResultsBlock';
|
|
5
5
|
export interface ChatHandle {
|
|
@@ -15,6 +15,12 @@ export interface ChatHandle {
|
|
|
15
15
|
newThread: () => void;
|
|
16
16
|
/** Load a stored conversation. No-op when persistence is off. */
|
|
17
17
|
switchThread: (threadId: string) => Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Send a prompt from outside the chat, e.g. one the shopper clicked elsewhere on the page.
|
|
20
|
+
* Pass `threadId` to send it into that conversation instead of the one on screen. Skipped
|
|
21
|
+
* while an answer is streaming. Opening the chat stays with you, as with `onClose`.
|
|
22
|
+
*/
|
|
23
|
+
sendMessage: (text: string, options?: SendMessageOptions) => void;
|
|
18
24
|
}
|
|
19
25
|
export interface ChatProps {
|
|
20
26
|
/** Called when the close button (✕) is clicked. The consumer controls visibility. */
|
|
@@ -46,6 +52,12 @@ export interface ChatProps {
|
|
|
46
52
|
translations?: Translations;
|
|
47
53
|
/** Seed the thread id (e.g. loaded from browser storage) to resume a prior conversation. Read once on mount. */
|
|
48
54
|
initialThreadId?: string;
|
|
55
|
+
/**
|
|
56
|
+
* A prompt sent once on mount, after any stored conversation loads, e.g. one the shopper
|
|
57
|
+
* clicked elsewhere before you opened the chat. Goes into `initialThreadId` when set.
|
|
58
|
+
* Read once on mount; use `sendMessage` on the ref once the chat is already open.
|
|
59
|
+
*/
|
|
60
|
+
initialPrompt?: string;
|
|
49
61
|
/** Fires with the stored conversations and the active one whenever either changes. Requires persistence. */
|
|
50
62
|
onThreadsChange?: (threads: ThreadSummary[], activeThreadId: string | null) => void;
|
|
51
63
|
/**
|
|
@@ -15,6 +15,7 @@ export declare class BackgroundTurn {
|
|
|
15
15
|
readonly ids: Set<string>;
|
|
16
16
|
/** Its writes, in order; a chat that takes the turn back writes after them. */
|
|
17
17
|
writes: Promise<void>;
|
|
18
|
+
private stopListening?;
|
|
18
19
|
constructor(turn: LiveTurn, params: {
|
|
19
20
|
store: ChatPersistence;
|
|
20
21
|
session: ChatSession;
|
|
@@ -24,6 +25,11 @@ export declare class BackgroundTurn {
|
|
|
24
25
|
save(): Promise<void>;
|
|
25
26
|
/** Later writes go into `store`. */
|
|
26
27
|
moveTo(store: ChatPersistence): void;
|
|
28
|
+
/** Stops the answer when another tab asks to, through the store it is saved into. */
|
|
29
|
+
listen(): void;
|
|
30
|
+
release(): void;
|
|
31
|
+
/** Stops the answer and stores it settled, as the stop button does on screen. */
|
|
32
|
+
abort(): void;
|
|
27
33
|
/** Stores the conversation settled, without awaiting: the page is going away. */
|
|
28
34
|
flush(): void;
|
|
29
35
|
}
|
|
@@ -18,6 +18,8 @@ interface Params {
|
|
|
18
18
|
turnRef: MutableRefObject<LiveTurn | null>;
|
|
19
19
|
/** Stop the answer currently streaming in this tab, if any. */
|
|
20
20
|
cancelStream: () => void;
|
|
21
|
+
/** Stop this tab's answer as the stop button does: settled and stored. */
|
|
22
|
+
abortTurn: () => void;
|
|
21
23
|
}
|
|
22
24
|
/** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
|
|
23
25
|
export default function useChatPersistence(params: Params): {
|
|
@@ -25,6 +27,8 @@ export default function useChatPersistence(params: Params): {
|
|
|
25
27
|
threads: import("../../types").ThreadSummary[];
|
|
26
28
|
activeThreadId: string | null;
|
|
27
29
|
foreignInFlight: boolean;
|
|
30
|
+
canAbortForeign: boolean;
|
|
31
|
+
abortForeign: () => void;
|
|
28
32
|
beginTurn: () => void;
|
|
29
33
|
screenOwner: (turn: LiveTurn) => TurnOwner;
|
|
30
34
|
clearHistory: () => void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Tracker } from '@constructor-io/constructorio-client-javascript/lib/types/constructorio';
|
|
2
|
-
import { AssistantTrackedItem } from '../types';
|
|
2
|
+
import { AgentButtonClickPayload, AssistantSubmitSource, AssistantTrackedItem } from '../types';
|
|
3
3
|
export interface UseAsaTrackingProps {
|
|
4
4
|
tracker?: Tracker;
|
|
5
5
|
section?: string;
|
|
@@ -40,8 +40,10 @@ export interface TrackSearchSubmitArgs {
|
|
|
40
40
|
intentResultId?: string;
|
|
41
41
|
groupId?: string;
|
|
42
42
|
}
|
|
43
|
+
export type TrackAgentButtonClickArgs = AgentButtonClickPayload;
|
|
43
44
|
export interface UseAsaTrackingReturn {
|
|
44
|
-
|
|
45
|
+
trackAgentButtonClick: (args: TrackAgentButtonClickArgs) => void;
|
|
46
|
+
trackSubmit: (intent: string, source?: AssistantSubmitSource) => void;
|
|
45
47
|
trackResultLoadStarted: (args: TrackResultLoadStartedArgs) => void;
|
|
46
48
|
trackResultLoadFinished: (args: TrackResultLoadFinishedArgs) => void;
|
|
47
49
|
trackResultClick: (args: TrackResultClickArgs) => void;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -10,9 +10,10 @@ export type { ButtonProps } from './components/Button/Button';
|
|
|
10
10
|
export { default as useAsaResults } from './hooks/useAsaResults';
|
|
11
11
|
export { default as useAsaTracking } from './hooks/useAsaTracking';
|
|
12
12
|
export { default as useCioAsaContext } from './hooks/useCioAsaContext';
|
|
13
|
-
export type { UseAsaTrackingProps, UseAsaTrackingReturn } from './hooks/useAsaTracking';
|
|
13
|
+
export type { UseAsaTrackingProps, UseAsaTrackingReturn, TrackAgentButtonClickArgs, } from './hooks/useAsaTracking';
|
|
14
14
|
export { normalizeItemToProduct } from './utils/productNormalizer';
|
|
15
15
|
export { clearPersistedConversations } from './utils/localStoragePersistence';
|
|
16
|
+
export { renderMarkdown } from './utils/markdown';
|
|
16
17
|
export type { Product, NormalizeOptions } from './utils/productNormalizer';
|
|
17
18
|
export { ConstructorIOClient } from './types';
|
|
18
|
-
export type { Nullable, CioAsaProviderProps, PersistConversationOptions, AsaContextValue, CioClientOptions, RequestConfigs, Formatters, UrlHelpers, QueryParamEncodingOptions, DefaultQueryStringMap, Translations, ChatMessage, ChatMessageStatus, FollowUpRefinement, ResultGroup, ResultGroupMeta, SearchResultEventRequest, UseAsaResultsOptions, UseChatReturn, ClearPersistedConversationsOptions, ThreadSummary, AsaCallbacks, AssistantSubmitSource, AssistantTrackedItem, ChatComponentOverrides, WelcomeScreenOverrides, ChatInputOverrides, AiMessageOverrides, ResultsBlockOverrides, ComponentOverrideProps, IncludeComponentOverrides, ChatHeaderRenderProps, ChatInputRenderProps, WelcomeScreenTitleRenderProps, WelcomeScreenInputRenderProps, SuggestedQuestionsRenderProps, UserMessageRenderProps, AiMessageLoaderRenderProps, AiMessageTextRenderProps, FollowUpRefinementRenderProps, ResultsGroupTitleRenderProps, ResultsViewMoreRenderProps, } from './types';
|
|
19
|
+
export type { Nullable, CioAsaProviderProps, PersistConversationOptions, AsaContextValue, CioClientOptions, RequestConfigs, Formatters, UrlHelpers, QueryParamEncodingOptions, DefaultQueryStringMap, Translations, ChatMessage, ChatMessageStatus, FollowUpRefinement, ResultGroup, ResultGroupMeta, SearchResultEventRequest, UseAsaResultsOptions, UseChatReturn, SendMessageOptions, ClearPersistedConversationsOptions, ThreadSummary, AsaCallbacks, AssistantSubmitSource, AssistantTrackedItem, AgentButtonClickMode, AgentButtonClickPayload, AgentButtonClickPageType, AgentButtonClickPlacement, ChatComponentOverrides, WelcomeScreenOverrides, ChatInputOverrides, AiMessageOverrides, ResultsBlockOverrides, ComponentOverrideProps, IncludeComponentOverrides, ChatHeaderRenderProps, ChatInputRenderProps, WelcomeScreenTitleRenderProps, WelcomeScreenInputRenderProps, SuggestedQuestionsRenderProps, UserMessageRenderProps, AiMessageLoaderRenderProps, AiMessageTextRenderProps, FollowUpRefinementRenderProps, ResultsGroupTitleRenderProps, ResultsViewMoreRenderProps, } from './types';
|