@constructor-io/constructorio-ui-asa 1.7.2 → 1.9.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 +21 -17
- package/dist/constructorio-ui-asa-bundled.js +17 -17
- package/lib/cjs/components/Button/Button.js +22 -3
- package/lib/cjs/components/Chat/AiMessage.js +2 -3
- package/lib/cjs/components/Chat/Chat.js +50 -9
- package/lib/cjs/components/CioAsa/CioAsa.js +6 -2
- package/lib/cjs/components/CioAsaProvider/CioAsaProvider.js +44 -3
- package/lib/cjs/components/ResultsBlock/ResultsGroup.js +5 -5
- package/lib/cjs/hooks/agentStream.js +113 -0
- package/lib/cjs/hooks/asaStreamHandlers.js +2 -1
- package/lib/cjs/hooks/persistence/backgroundTurns.js +121 -0
- package/lib/cjs/hooks/persistence/useChatPersistence.js +485 -0
- package/lib/cjs/hooks/persistence/useForeignStream.js +34 -0
- package/lib/cjs/hooks/persistence/useThreadList.js +45 -0
- package/lib/cjs/hooks/persistence/useWriteQueue.js +17 -0
- package/lib/cjs/hooks/useAsaResults.js +119 -159
- package/lib/cjs/hooks/useAsaTracking.js +33 -18
- package/lib/cjs/hooks/useIsMounted.js +15 -0
- package/lib/cjs/hooks/useLatest.js +10 -0
- package/lib/cjs/hooks/useViewportTracking.js +4 -3
- package/lib/cjs/index.js +3 -1
- package/lib/cjs/utils/chatRecordValidation.js +54 -0
- package/lib/cjs/utils/chatSession.js +98 -0
- package/lib/cjs/utils/chatThreads.js +248 -0
- package/lib/cjs/utils/clientOptions.js +11 -0
- package/lib/cjs/utils/localStoragePersistence.js +391 -0
- package/lib/cjs/utils/translate.js +1 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/Button/Button.js +26 -3
- package/lib/mjs/components/Chat/AiMessage.js +2 -3
- package/lib/mjs/components/Chat/Chat.js +48 -9
- package/lib/mjs/components/CioAsa/CioAsa.js +6 -2
- package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +44 -4
- package/lib/mjs/components/ResultsBlock/ResultsGroup.js +2 -1
- package/lib/mjs/hooks/agentStream.js +106 -0
- package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
- package/lib/mjs/hooks/persistence/backgroundTurns.js +120 -0
- package/lib/mjs/hooks/persistence/useChatPersistence.js +474 -0
- package/lib/mjs/hooks/persistence/useForeignStream.js +31 -0
- package/lib/mjs/hooks/persistence/useThreadList.js +41 -0
- package/lib/mjs/hooks/persistence/useWriteQueue.js +14 -0
- package/lib/mjs/hooks/useAsaResults.js +113 -156
- package/lib/mjs/hooks/useAsaTracking.js +42 -17
- package/lib/mjs/hooks/useIsMounted.js +12 -0
- package/lib/mjs/hooks/useLatest.js +7 -0
- package/lib/mjs/hooks/useViewportTracking.js +3 -3
- package/lib/mjs/index.js +1 -0
- package/lib/mjs/utils/chatRecordValidation.js +51 -0
- package/lib/mjs/utils/chatSession.js +86 -0
- package/lib/mjs/utils/chatThreads.js +219 -0
- package/lib/mjs/utils/clientOptions.js +7 -0
- package/lib/mjs/utils/localStoragePersistence.js +373 -0
- package/lib/mjs/utils/translate.js +1 -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 +20 -2
- package/lib/types/components/CioAsa/CioAsa.d.ts +1 -1
- package/lib/types/hooks/agentStream.d.ts +48 -0
- package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
- package/lib/types/hooks/persistence/backgroundTurns.d.ts +43 -0
- package/lib/types/hooks/persistence/useChatPersistence.d.ts +34 -0
- package/lib/types/hooks/persistence/useForeignStream.d.ts +9 -0
- package/lib/types/hooks/persistence/useThreadList.d.ts +9 -0
- package/lib/types/hooks/persistence/useWriteQueue.d.ts +5 -0
- package/lib/types/hooks/useAsaTracking.d.ts +6 -2
- package/lib/types/hooks/useIsMounted.d.ts +3 -0
- package/lib/types/hooks/useLatest.d.ts +3 -0
- package/lib/types/index.d.ts +3 -2
- package/lib/types/types.d.ts +170 -5
- package/lib/types/utils/chatRecordValidation.d.ts +2 -0
- package/lib/types/utils/chatSession.d.ts +45 -0
- package/lib/types/utils/chatThreads.d.ts +63 -0
- package/lib/types/utils/clientOptions.d.ts +5 -0
- package/lib/types/utils/localStoragePersistence.d.ts +26 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +2 -2
|
@@ -1,188 +1,140 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useCioAsaContext } from './useCioAsaContext';
|
|
3
|
-
import { handleSearchResult, handleMessage, handleFollowUpRefinement, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
|
|
4
3
|
import useAsaTracking from './useAsaTracking';
|
|
4
|
+
import useChatPersistence from './persistence/useChatPersistence';
|
|
5
|
+
import { startTurn } from './agentStream';
|
|
6
|
+
import { createChatSession, nextMessageId } from '../utils/chatSession';
|
|
7
|
+
import { normalizeItemToProduct } from '../utils/productNormalizer';
|
|
8
|
+
import { isLocalThreadId } from '../utils/chatThreads';
|
|
9
|
+
import useLatest from './useLatest';
|
|
5
10
|
export default function useAsaResults(options) {
|
|
6
|
-
const [messages, setMessages] = useState([]);
|
|
7
|
-
const [isStreaming, setIsStreaming] = useState(false);
|
|
8
|
-
const isStreamingRef = useRef(false);
|
|
9
|
-
const killSwitchRef = useRef(false);
|
|
10
|
-
const readerRef = useRef(null);
|
|
11
|
-
const threadIdRef = useRef(options?.initialThreadId ?? null);
|
|
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);
|
|
16
11
|
const contextValue = useCioAsaContext();
|
|
17
12
|
if (!contextValue) {
|
|
18
13
|
throw new Error('useAsaResults must be used within a CioAsaProvider.');
|
|
19
14
|
}
|
|
20
|
-
const { cioClient, staticRequestConfigs, callbacks, section } = contextValue;
|
|
15
|
+
const { cioClient, staticRequestConfigs, callbacks, section, persistence, persistenceScope, persistenceIndex, } = contextValue;
|
|
21
16
|
const { domain } = staticRequestConfigs || {};
|
|
22
17
|
if (!cioClient || !domain) {
|
|
23
18
|
throw new Error('useAsaResults requires a configured cioClient and domain. Check your CioAsaProvider props.');
|
|
24
19
|
}
|
|
20
|
+
const [messages, setMessages] = useState([]);
|
|
21
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
22
|
+
const normalizeItemRef = useLatest(options?.normalizeItem);
|
|
23
|
+
const sessionRef = useRef(null);
|
|
24
|
+
if (!sessionRef.current) {
|
|
25
|
+
sessionRef.current = createChatSession(options?.initialThreadId, (item) => (normalizeItemRef.current ?? normalizeItemToProduct)(item));
|
|
26
|
+
}
|
|
27
|
+
const session = sessionRef.current;
|
|
28
|
+
const turnRef = useRef(null);
|
|
25
29
|
const tracking = useAsaTracking({
|
|
26
30
|
tracker: cioClient.tracker,
|
|
27
31
|
section,
|
|
28
|
-
threadId:
|
|
32
|
+
threadId: session.serverThreadId ?? undefined,
|
|
29
33
|
});
|
|
30
|
-
const trackingRef =
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
-
}
|
|
34
|
+
const trackingRef = useLatest(tracking);
|
|
35
|
+
const callbacksRef = useLatest(callbacks);
|
|
36
|
+
const staticRequestConfigsRef = useLatest(staticRequestConfigs);
|
|
37
|
+
/** Stops this tab's stream, if any. `readAgentStream` settles its `done` without touching state. */
|
|
38
|
+
const cancelStream = useCallback(() => {
|
|
39
|
+
turnRef.current?.cancel();
|
|
40
|
+
turnRef.current = null;
|
|
50
41
|
}, []);
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
const store = useChatPersistence({
|
|
43
|
+
store: persistence,
|
|
44
|
+
scope: persistenceScope,
|
|
45
|
+
index: persistenceIndex,
|
|
46
|
+
session,
|
|
47
|
+
initialThreadId: options?.initialThreadId,
|
|
48
|
+
messages,
|
|
49
|
+
setMessages,
|
|
50
|
+
isStreaming,
|
|
51
|
+
setIsStreaming,
|
|
52
|
+
turnRef,
|
|
53
|
+
cancelStream,
|
|
54
|
+
});
|
|
55
|
+
const { beginTurn, screenOwner } = store;
|
|
56
|
+
useEffect(() => cancelStream, [cancelStream]);
|
|
57
|
+
const sendToCurrentThread = useCallback((text, source) => {
|
|
56
58
|
const intent = text.trim();
|
|
57
|
-
if (!intent ||
|
|
59
|
+
if (!intent || session.isStreaming || session.foreignInFlight)
|
|
58
60
|
return;
|
|
59
|
-
|
|
61
|
+
beginTurn();
|
|
62
|
+
trackingRef.current.trackSubmit(intent, source);
|
|
60
63
|
callbacksRef.current?.onAssistantSubmit?.({ intent, source });
|
|
61
64
|
const userMessage = {
|
|
62
|
-
id: nextMessageId(),
|
|
65
|
+
id: nextMessageId(session),
|
|
63
66
|
role: 'user',
|
|
64
67
|
text: intent,
|
|
65
68
|
status: 'done',
|
|
66
69
|
};
|
|
67
70
|
const assistantMessage = {
|
|
68
|
-
id: nextMessageId(),
|
|
71
|
+
id: nextMessageId(session),
|
|
69
72
|
role: 'assistant',
|
|
70
73
|
text: '',
|
|
71
74
|
groups: [],
|
|
72
75
|
status: 'loading',
|
|
73
76
|
intent,
|
|
74
77
|
};
|
|
75
|
-
activeAssistantIdRef.current = assistantMessage.id;
|
|
76
78
|
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
|
77
79
|
setIsStreaming(true);
|
|
78
|
-
|
|
79
|
-
killSwitchRef.current = false;
|
|
80
|
-
// `intent` is passed as the first argument and `threadId` is managed per-stream
|
|
81
|
-
// below, so both are stripped here. Everything else configured on the provider
|
|
82
|
-
// (filters, guard, numResultsPerEvent, numResultEvents, qsParam,
|
|
83
|
-
// preFilterExpression, fmtOptions, ...) is forwarded to the agent as-is.
|
|
80
|
+
session.isStreaming = true;
|
|
84
81
|
const agentParams = { ...staticRequestConfigsRef.current };
|
|
85
82
|
delete agentParams.intent;
|
|
86
83
|
delete agentParams.threadId;
|
|
87
84
|
const stream = cioClient.agent.getAgentResultsStream(intent, {
|
|
88
85
|
...agentParams,
|
|
89
86
|
domain,
|
|
90
|
-
...(
|
|
87
|
+
...(session.serverThreadId && { threadId: session.serverThreadId }),
|
|
91
88
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
89
|
+
turnRef.current = startTurn(stream, assistantMessage, screenOwner, {
|
|
90
|
+
onLoadStart: (intentResultId) => {
|
|
91
|
+
const args = { intent, intentResultId };
|
|
92
|
+
trackingRef.current.trackResultLoadStarted(args);
|
|
93
|
+
callbacksRef.current?.onResultLoadStart?.(args);
|
|
94
|
+
},
|
|
95
|
+
onFinish: ({ threadId, ...result }) => {
|
|
96
|
+
const args = { intent, ...result };
|
|
97
|
+
// The turn may have finished in the background, after the chat moved to another thread.
|
|
98
|
+
trackingRef.current.trackResultLoadFinished({ ...args, threadId });
|
|
99
|
+
callbacksRef.current?.onResultLoadFinish?.(args);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}, [
|
|
103
|
+
cioClient,
|
|
104
|
+
domain,
|
|
105
|
+
session,
|
|
106
|
+
beginTurn,
|
|
107
|
+
screenOwner,
|
|
108
|
+
trackingRef,
|
|
109
|
+
callbacksRef,
|
|
110
|
+
staticRequestConfigsRef,
|
|
111
|
+
]);
|
|
112
|
+
const { newThread, switchThread } = store;
|
|
113
|
+
/**
|
|
114
|
+
* Sends `text`. With `threadId`, first moves to that conversation, loading it from storage
|
|
115
|
+
* when persistence has it and otherwise continuing it on the server with an empty screen.
|
|
116
|
+
*/
|
|
117
|
+
const sendMessage = useCallback((text, source = 'input', { threadId } = {}) => {
|
|
118
|
+
if (!threadId ||
|
|
119
|
+
threadId === session.serverThreadId ||
|
|
120
|
+
threadId === session.storageThreadId) {
|
|
121
|
+
sendToCurrentThread(text, source);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
113
124
|
(async () => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const finishArgs = {
|
|
124
|
-
intent,
|
|
125
|
-
searchResultCount: streamState.groupCount,
|
|
126
|
-
intentResultId: streamState.intentResultId,
|
|
127
|
-
};
|
|
128
|
-
trackingRef.current.trackResultLoadFinished(finishArgs);
|
|
129
|
-
callbacksRef.current?.onResultLoadFinish?.(finishArgs);
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
const { type, data } = res.value;
|
|
133
|
-
// `intent_result_id` is shared across the stream; capture it from the first
|
|
134
|
-
// event that carries it so load-start/finish can attribute correctly.
|
|
135
|
-
if (!streamState.intentResultId && data?.intent_result_id) {
|
|
136
|
-
streamState.intentResultId = data.intent_result_id;
|
|
137
|
-
patchAssistant({ intentResultId: streamState.intentResultId });
|
|
138
|
-
}
|
|
139
|
-
if (type === 'start') {
|
|
140
|
-
if (data?.thread_id) {
|
|
141
|
-
threadIdRef.current = data.thread_id;
|
|
142
|
-
patchAssistant({ threadId: data.thread_id });
|
|
143
|
-
}
|
|
144
|
-
fireLoadStart();
|
|
145
|
-
}
|
|
146
|
-
else if (type === 'group') {
|
|
147
|
-
streamState.pendingGroup = {
|
|
148
|
-
display_name: data?.display_name ?? data?.group ?? '',
|
|
149
|
-
value: data?.value ?? data?.group ?? '',
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
else if (type === 'search_result') {
|
|
153
|
-
fireLoadStart();
|
|
154
|
-
streamState.groupCount += 1;
|
|
155
|
-
streamState.pendingGroup = handleSearchResult(data, streamState.pendingGroup, assistantMessage.id, setMessages);
|
|
156
|
-
}
|
|
157
|
-
else if (type === 'message') {
|
|
158
|
-
fireLoadStart();
|
|
159
|
-
handleMessage(data, assistantMessage.id, setMessages);
|
|
160
|
-
}
|
|
161
|
-
else if (type === 'follow_up_refinement') {
|
|
162
|
-
fireLoadStart();
|
|
163
|
-
handleFollowUpRefinement(data, assistantMessage.id, setMessages);
|
|
164
|
-
}
|
|
165
|
-
else if (type === 'server_error') {
|
|
166
|
-
handleServerError(assistantMessage.id, setMessages);
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
handleStreamError(assistantMessage.id, setMessages);
|
|
173
|
-
}
|
|
174
|
-
finally {
|
|
175
|
-
if (readerRef.current === reader) {
|
|
176
|
-
reader.cancel();
|
|
177
|
-
readerRef.current = null;
|
|
178
|
-
activeAssistantIdRef.current = null;
|
|
179
|
-
setIsStreaming(false);
|
|
180
|
-
isStreamingRef.current = false;
|
|
181
|
-
}
|
|
125
|
+
if (persistence)
|
|
126
|
+
await switchThread(threadId);
|
|
127
|
+
else
|
|
128
|
+
newThread();
|
|
129
|
+
// A turn that started meanwhile (or one still streaming in the background) owns the screen.
|
|
130
|
+
if (session.isStreaming || session.foreignInFlight)
|
|
131
|
+
return;
|
|
132
|
+
if (!session.serverThreadId && !isLocalThreadId(threadId)) {
|
|
133
|
+
session.serverThreadId = threadId;
|
|
182
134
|
}
|
|
135
|
+
sendToCurrentThread(text, source);
|
|
183
136
|
})();
|
|
184
|
-
}, [
|
|
185
|
-
useEffect(() => () => teardownStream(), [teardownStream]);
|
|
137
|
+
}, [session, persistence, newThread, switchThread, sendToCurrentThread]);
|
|
186
138
|
/**
|
|
187
139
|
* Cancels the in-flight request while keeping the conversation: the partial reply is
|
|
188
140
|
* settled as `done` (so the typing indicator stops and streamed text survives) and the
|
|
@@ -199,13 +151,12 @@ export default function useAsaResults(options) {
|
|
|
199
151
|
* `assistant_result_load_started` with no matching finished event.
|
|
200
152
|
*/
|
|
201
153
|
const abort = useCallback(() => {
|
|
202
|
-
if (!
|
|
154
|
+
if (!session.isStreaming)
|
|
203
155
|
return;
|
|
204
|
-
|
|
156
|
+
const abortedId = turnRef.current?.assistantId;
|
|
157
|
+
cancelStream();
|
|
158
|
+
session.isStreaming = false;
|
|
205
159
|
setIsStreaming(false);
|
|
206
|
-
isStreamingRef.current = false;
|
|
207
|
-
const abortedId = activeAssistantIdRef.current;
|
|
208
|
-
activeAssistantIdRef.current = null;
|
|
209
160
|
if (abortedId) {
|
|
210
161
|
setMessages((prev) => prev.flatMap((msg) => {
|
|
211
162
|
if (msg.id !== abortedId)
|
|
@@ -215,14 +166,20 @@ export default function useAsaResults(options) {
|
|
|
215
166
|
return [{ ...msg, status: 'done' }];
|
|
216
167
|
}));
|
|
217
168
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
169
|
+
// With persistence on, the cancelled turn is stored as it stands.
|
|
170
|
+
session.dirty = true;
|
|
171
|
+
}, [session, cancelStream]);
|
|
172
|
+
return {
|
|
173
|
+
messages,
|
|
174
|
+
sendMessage,
|
|
175
|
+
isStreaming: isStreaming || store.foreignInFlight,
|
|
176
|
+
canAbort: isStreaming,
|
|
177
|
+
abort,
|
|
178
|
+
clearHistory: store.clearHistory,
|
|
179
|
+
isHydrating: store.isHydrating,
|
|
180
|
+
threads: store.threads,
|
|
181
|
+
activeThreadId: store.activeThreadId,
|
|
182
|
+
newThread,
|
|
183
|
+
switchThread,
|
|
184
|
+
};
|
|
228
185
|
}
|
|
@@ -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,31 +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
|
-
}, [
|
|
31
|
-
const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId }) => {
|
|
32
|
-
|
|
52
|
+
}, [tracker, base]);
|
|
53
|
+
const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId, threadId: turnThreadId, }) => {
|
|
54
|
+
tracker?.trackAssistantResultLoadFinished({
|
|
33
55
|
intent,
|
|
34
56
|
searchResultCount,
|
|
35
57
|
...(intentResultId && { intentResultId }),
|
|
36
58
|
...base,
|
|
59
|
+
...(turnThreadId && { threadId: turnThreadId }),
|
|
37
60
|
});
|
|
38
|
-
}, [
|
|
61
|
+
}, [tracker, base]);
|
|
39
62
|
const trackResultClick = useCallback(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
|
|
40
|
-
|
|
63
|
+
tracker?.trackAssistantResultClick({
|
|
41
64
|
intent,
|
|
42
65
|
searchResultId,
|
|
43
66
|
...(intentResultId && { intentResultId }),
|
|
@@ -46,9 +69,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
46
69
|
...(variationId && { variationId }),
|
|
47
70
|
...base,
|
|
48
71
|
});
|
|
49
|
-
}, [
|
|
72
|
+
}, [tracker, base]);
|
|
50
73
|
const trackResultView = useCallback(({ intent, searchResultId, numResultsViewed, intentResultId, items }) => {
|
|
51
|
-
|
|
74
|
+
tracker?.trackAssistantResultView({
|
|
52
75
|
intent,
|
|
53
76
|
searchResultId,
|
|
54
77
|
numResultsViewed,
|
|
@@ -56,9 +79,9 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
56
79
|
...(items && items.length > 0 && { items }),
|
|
57
80
|
...base,
|
|
58
81
|
});
|
|
59
|
-
}, [
|
|
82
|
+
}, [tracker, base]);
|
|
60
83
|
const trackSearchSubmit = useCallback(({ intent, searchTerm, userInput, searchResultId, intentResultId, groupId, }) => {
|
|
61
|
-
|
|
84
|
+
tracker?.trackAssistantSearchSubmit({
|
|
62
85
|
intent,
|
|
63
86
|
searchTerm,
|
|
64
87
|
userInput,
|
|
@@ -67,11 +90,12 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
67
90
|
...(groupId && { groupId }),
|
|
68
91
|
...base,
|
|
69
92
|
});
|
|
70
|
-
}, [
|
|
93
|
+
}, [tracker, base]);
|
|
71
94
|
return useMemo(() => {
|
|
72
|
-
if (!
|
|
95
|
+
if (!tracker)
|
|
73
96
|
return NOOP_TRACKING;
|
|
74
97
|
return {
|
|
98
|
+
trackAgentButtonClick,
|
|
75
99
|
trackSubmit,
|
|
76
100
|
trackResultLoadStarted,
|
|
77
101
|
trackResultLoadFinished,
|
|
@@ -80,7 +104,8 @@ export default function useAsaTracking({ tracker, section, threadId, }) {
|
|
|
80
104
|
trackSearchSubmit,
|
|
81
105
|
};
|
|
82
106
|
}, [
|
|
83
|
-
|
|
107
|
+
tracker,
|
|
108
|
+
trackAgentButtonClick,
|
|
84
109
|
trackSubmit,
|
|
85
110
|
trackResultLoadStarted,
|
|
86
111
|
trackResultLoadFinished,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
/** A ref that is `true` while mounted, for async work that must not land after unmount. */
|
|
3
|
+
export default function useIsMounted() {
|
|
4
|
+
const ref = useRef(true);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
ref.current = true;
|
|
7
|
+
return () => {
|
|
8
|
+
ref.current = false;
|
|
9
|
+
};
|
|
10
|
+
}, []);
|
|
11
|
+
return ref;
|
|
12
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import useLatest from './useLatest';
|
|
2
3
|
/**
|
|
3
4
|
* Fires `onView` once when the observed element scrolls into the viewport, modeling a
|
|
4
5
|
* per-pod `assistant_search_result_view` impression. Unlike the PIA widget-level view
|
|
5
6
|
* tracker, this fires a single impression per pod (not repeated enter/exit cycles).
|
|
6
7
|
*/
|
|
7
8
|
export default function useViewportTracking({ onView, enabled = true, }) {
|
|
8
|
-
const onViewRef =
|
|
9
|
-
onViewRef.current = onView;
|
|
9
|
+
const onViewRef = useLatest(onView);
|
|
10
10
|
const firedRef = useRef(false);
|
|
11
11
|
const cleanupRef = useRef(null);
|
|
12
12
|
const ref = useCallback((node) => {
|
|
@@ -27,7 +27,7 @@ export default function useViewportTracking({ onView, enabled = true, }) {
|
|
|
27
27
|
}, { threshold: 0.5 });
|
|
28
28
|
observer.observe(node);
|
|
29
29
|
cleanupRef.current = () => observer.disconnect();
|
|
30
|
-
}, [enabled]);
|
|
30
|
+
}, [enabled, onViewRef]);
|
|
31
31
|
useEffect(() => () => {
|
|
32
32
|
if (cleanupRef.current)
|
|
33
33
|
cleanupRef.current();
|
package/lib/mjs/index.js
CHANGED
|
@@ -10,5 +10,6 @@ export { default as useAsaTracking } from './hooks/useAsaTracking';
|
|
|
10
10
|
export { default as useCioAsaContext } from './hooks/useCioAsaContext';
|
|
11
11
|
// Utils
|
|
12
12
|
export { normalizeItemToProduct } from './utils/productNormalizer';
|
|
13
|
+
export { clearPersistedConversations } from './utils/localStoragePersistence';
|
|
13
14
|
// Re-exported client value from the JS client (used to construct/type a cioClient)
|
|
14
15
|
export { ConstructorIOClient } from './types';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PERSISTED_CHAT_VERSION } from './chatThreads';
|
|
2
|
+
const MESSAGE_ROLES = new Set(['user', 'assistant']);
|
|
3
|
+
const MESSAGE_STATUSES = new Set(['idle', 'loading', 'streaming', 'done', 'error']);
|
|
4
|
+
function isProduct(value) {
|
|
5
|
+
if (!value || typeof value !== 'object')
|
|
6
|
+
return false;
|
|
7
|
+
const p = value;
|
|
8
|
+
return typeof p.id === 'string' && typeof p.name === 'string';
|
|
9
|
+
}
|
|
10
|
+
function isResultGroup(value) {
|
|
11
|
+
if (!value || typeof value !== 'object')
|
|
12
|
+
return false;
|
|
13
|
+
const g = value;
|
|
14
|
+
return (Boolean(g.group) &&
|
|
15
|
+
typeof g.group === 'object' &&
|
|
16
|
+
typeof g.group.display_name === 'string' &&
|
|
17
|
+
Array.isArray(g.searchResults) &&
|
|
18
|
+
g.searchResults.every((r) => Boolean(r) && typeof r === 'object') &&
|
|
19
|
+
(g.products === undefined || (Array.isArray(g.products) && g.products.every(isProduct))));
|
|
20
|
+
}
|
|
21
|
+
function isRefinement(value) {
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return false;
|
|
24
|
+
const r = value;
|
|
25
|
+
return (typeof r.question === 'string' &&
|
|
26
|
+
Array.isArray(r.options) &&
|
|
27
|
+
r.options.every((o) => typeof o === 'string'));
|
|
28
|
+
}
|
|
29
|
+
function isChatMessage(value) {
|
|
30
|
+
if (!value || typeof value !== 'object')
|
|
31
|
+
return false;
|
|
32
|
+
const m = value;
|
|
33
|
+
return (typeof m.id === 'string' &&
|
|
34
|
+
MESSAGE_ROLES.has(m.role) &&
|
|
35
|
+
typeof m.text === 'string' &&
|
|
36
|
+
MESSAGE_STATUSES.has(m.status) &&
|
|
37
|
+
(m.groups === undefined || (Array.isArray(m.groups) && m.groups.every(isResultGroup))) &&
|
|
38
|
+
(m.refinement === undefined || isRefinement(m.refinement)));
|
|
39
|
+
}
|
|
40
|
+
export default function isPersistedChat(value) {
|
|
41
|
+
if (!value || typeof value !== 'object')
|
|
42
|
+
return false;
|
|
43
|
+
const chat = value;
|
|
44
|
+
return (chat.version === PERSISTED_CHAT_VERSION &&
|
|
45
|
+
typeof chat.threadId === 'string' &&
|
|
46
|
+
Array.isArray(chat.messages) &&
|
|
47
|
+
chat.messages.every(isChatMessage) &&
|
|
48
|
+
typeof chat.createdAt === 'number' &&
|
|
49
|
+
typeof chat.updatedAt === 'number' &&
|
|
50
|
+
(chat.owner === undefined || typeof chat.owner === 'string'));
|
|
51
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { PERSISTED_CHAT_VERSION, compactMessages, createLocalThreadId, getTabId, isLocalThreadId, nextMessageCounter, randomId, } from './chatThreads';
|
|
2
|
+
import { normalizeItemToProduct } from './productNormalizer';
|
|
3
|
+
export function createChatSession(initialThreadId, normalizeItem = normalizeItemToProduct) {
|
|
4
|
+
return {
|
|
5
|
+
normalizeItem,
|
|
6
|
+
serverThreadId: initialThreadId && !isLocalThreadId(initialThreadId) ? initialThreadId : null,
|
|
7
|
+
storageThreadId: null,
|
|
8
|
+
orphanIds: [],
|
|
9
|
+
createdAt: null,
|
|
10
|
+
lastSyncedAt: 0,
|
|
11
|
+
dirty: false,
|
|
12
|
+
interacted: false,
|
|
13
|
+
loadRequest: 0,
|
|
14
|
+
isStreaming: false,
|
|
15
|
+
foreignInFlight: false,
|
|
16
|
+
idCounter: 0,
|
|
17
|
+
idSuffix: randomId().slice(0, 6),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Forget the current conversation. Returns the id it was stored under, if any. */
|
|
21
|
+
export function resetConversation(session) {
|
|
22
|
+
const storedId = session.storageThreadId;
|
|
23
|
+
session.serverThreadId = null;
|
|
24
|
+
session.storageThreadId = null;
|
|
25
|
+
session.orphanIds = [];
|
|
26
|
+
session.createdAt = null;
|
|
27
|
+
session.lastSyncedAt = 0;
|
|
28
|
+
session.dirty = false;
|
|
29
|
+
session.loadRequest += 1;
|
|
30
|
+
session.isStreaming = false;
|
|
31
|
+
session.foreignInFlight = false;
|
|
32
|
+
return storedId;
|
|
33
|
+
}
|
|
34
|
+
export function nextMessageId(session) {
|
|
35
|
+
session.idCounter += 1;
|
|
36
|
+
return `msg-${session.idCounter}-${Date.now()}-${session.idSuffix}`;
|
|
37
|
+
}
|
|
38
|
+
/** Whether `messageId` was minted by this session rather than by another tab. */
|
|
39
|
+
export function isOwnMessage(session, messageId) {
|
|
40
|
+
return messageId.endsWith(`-${session.idSuffix}`);
|
|
41
|
+
}
|
|
42
|
+
/** Makes a stored conversation the one this session continues. */
|
|
43
|
+
export function adoptStoredChat(session, chat) {
|
|
44
|
+
session.storageThreadId = chat.threadId;
|
|
45
|
+
session.createdAt = chat.createdAt;
|
|
46
|
+
session.serverThreadId = isLocalThreadId(chat.threadId) ? null : chat.threadId;
|
|
47
|
+
session.idCounter = nextMessageCounter(chat.messages);
|
|
48
|
+
session.lastSyncedAt = chat.updatedAt;
|
|
49
|
+
}
|
|
50
|
+
/** A copy that keeps the conversation `session` holds while `session` itself moves on. */
|
|
51
|
+
export function forkSession(session) {
|
|
52
|
+
return { ...session, orphanIds: [...session.orphanIds] };
|
|
53
|
+
}
|
|
54
|
+
/** Makes `session` continue the conversation `from` kept, showing `messages`. */
|
|
55
|
+
export function adoptSession(session, from, messages) {
|
|
56
|
+
session.serverThreadId = from.serverThreadId;
|
|
57
|
+
session.storageThreadId = from.storageThreadId;
|
|
58
|
+
session.orphanIds = [...from.orphanIds];
|
|
59
|
+
session.createdAt = from.createdAt;
|
|
60
|
+
session.lastSyncedAt = from.lastSyncedAt;
|
|
61
|
+
session.idCounter = nextMessageCounter(messages);
|
|
62
|
+
}
|
|
63
|
+
/** Builds the record to write and moves the session onto its key; `staleIds` are the records to retire once stored. */
|
|
64
|
+
export function prepareSnapshot(session, messages, now = Date.now()) {
|
|
65
|
+
const previousId = session.storageThreadId;
|
|
66
|
+
const threadId = session.serverThreadId ?? previousId ?? createLocalThreadId();
|
|
67
|
+
const staleIds = [previousId !== threadId ? previousId : null, ...session.orphanIds].filter((id) => id !== null && id !== threadId);
|
|
68
|
+
// Strictly increasing so other tabs read a same-millisecond save as newer.
|
|
69
|
+
const updatedAt = Math.max(now, session.lastSyncedAt + 1);
|
|
70
|
+
const createdAt = session.createdAt ?? updatedAt;
|
|
71
|
+
session.storageThreadId = threadId;
|
|
72
|
+
session.orphanIds = [];
|
|
73
|
+
session.createdAt = createdAt;
|
|
74
|
+
session.lastSyncedAt = updatedAt;
|
|
75
|
+
return {
|
|
76
|
+
staleIds: Array.from(new Set(staleIds)),
|
|
77
|
+
snapshot: {
|
|
78
|
+
version: PERSISTED_CHAT_VERSION,
|
|
79
|
+
threadId,
|
|
80
|
+
messages: compactMessages(messages, session.normalizeItem),
|
|
81
|
+
createdAt,
|
|
82
|
+
updatedAt,
|
|
83
|
+
owner: getTabId(),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|