@constructor-io/constructorio-ui-pia 2.16.0 → 2.17.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 +15 -13
- package/dist/constructorio-ui-pia-bundled.js +47 -47
- package/lib/cjs/components/CioPia/CioPiaQna.js +14 -4
- package/lib/cjs/hooks/useAnswerResults.js +17 -1
- package/lib/cjs/hooks/useCioPia.js +2 -1
- package/lib/cjs/hooks/useConversation.js +40 -18
- package/lib/cjs/hooks/usePersistedConversation.js +71 -0
- package/lib/cjs/hooks/usePiaClient.js +8 -1
- package/lib/cjs/index.js +4 -1
- package/lib/cjs/utils/conversationStorage.js +141 -0
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/CioPia/CioPiaQna.js +12 -2
- package/lib/mjs/hooks/useAnswerResults.js +17 -1
- package/lib/mjs/hooks/useCioPia.js +2 -1
- package/lib/mjs/hooks/useConversation.js +38 -16
- package/lib/mjs/hooks/usePersistedConversation.js +73 -0
- package/lib/mjs/hooks/usePiaClient.js +8 -1
- package/lib/mjs/index.js +2 -0
- package/lib/mjs/utils/conversationStorage.js +129 -0
- package/lib/mjs/version.js +1 -1
- package/lib/types/components/CioPia/types.d.ts +16 -1
- package/lib/types/hooks/useAnswerResults.d.ts +2 -0
- package/lib/types/hooks/useCioPia.d.ts +2 -0
- package/lib/types/hooks/useConversation.d.ts +4 -1
- package/lib/types/hooks/usePersistedConversation.d.ts +18 -0
- package/lib/types/hooks/usePiaClient.d.ts +3 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/types.d.ts +14 -0
- package/lib/types/utils/conversationStorage.d.ts +20 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +3 -1
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const { question, answer, items } = entry;
|
|
6
|
-
return (typeof question === 'string' &&
|
|
7
|
-
typeof answer === 'string' &&
|
|
8
|
-
(items === undefined || items === null || Array.isArray(items)));
|
|
9
|
-
}
|
|
2
|
+
import { isRestorableEntry } from '../utils/conversationStorage';
|
|
3
|
+
// `id` is the React key, so ids from outside the widget cannot be trusted to be unique.
|
|
4
|
+
const renumber = (entries) => entries.map((entry, index) => ({ ...entry, id: index + 1 }));
|
|
10
5
|
// The seed comes from the host's own storage, typed or not: a malformed one must not take the widget down.
|
|
11
6
|
function restoreHistory(seed) {
|
|
12
7
|
if (!Array.isArray(seed)) {
|
|
@@ -17,21 +12,27 @@ function restoreHistory(seed) {
|
|
|
17
12
|
if (entries.length < seed.length) {
|
|
18
13
|
console.warn(`[CioPia] ${seed.length - entries.length} initialConversationHistory entries were ignored. Each needs a string question and answer, and items must be an array.`);
|
|
19
14
|
}
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
return renumber(entries);
|
|
16
|
+
}
|
|
17
|
+
function initialHistory(isConversation, seed, persisted) {
|
|
18
|
+
if (!isConversation)
|
|
19
|
+
return [];
|
|
20
|
+
if (seed !== undefined)
|
|
21
|
+
return restoreHistory(seed);
|
|
22
|
+
return persisted ? renumber(persisted.history) : [];
|
|
22
23
|
}
|
|
23
|
-
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }) {
|
|
24
|
+
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, persisted, }) {
|
|
24
25
|
const { suggestedQuestions, answers, threadId } = pia;
|
|
25
26
|
const { getAnswer } = answers;
|
|
26
27
|
const context = useMemo(() => ({ itemId, threadId }), [itemId, threadId]);
|
|
27
28
|
const [currentQuestion, setCurrentQuestion] = useState('');
|
|
28
29
|
const [displayedQuestions, setDisplayedQuestions] = useState([]);
|
|
29
|
-
const [conversationHistory, setConversationHistory] = useState(() => isConversation
|
|
30
|
-
? restoreHistory(initialConversationHistory)
|
|
31
|
-
: []);
|
|
30
|
+
const [conversationHistory, setConversationHistory] = useState(() => initialHistory(isConversation, initialConversationHistory, persisted));
|
|
32
31
|
const entryIdRef = useRef(conversationHistory.length);
|
|
33
32
|
const conversationHistoryRef = useRef(conversationHistory);
|
|
34
33
|
const prevItemIdRef = useRef(itemId);
|
|
34
|
+
const persistedRef = useRef(persisted);
|
|
35
|
+
const prevPersistedKeyRef = useRef(persisted?.key);
|
|
35
36
|
const prevAnswerDataRef = useRef(answers.data);
|
|
36
37
|
const hasTrackedCurrentAnswerRef = useRef(false);
|
|
37
38
|
const answersRef = useRef(answers);
|
|
@@ -58,6 +59,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
58
59
|
useEffect(() => {
|
|
59
60
|
conversationHistoryRef.current = conversationHistory;
|
|
60
61
|
}, [conversationHistory]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
persistedRef.current = persisted;
|
|
64
|
+
}, [persisted]);
|
|
61
65
|
const submitQuestion = useCallback((question, source) => {
|
|
62
66
|
lastSourceRef.current = source;
|
|
63
67
|
lastQuestionRef.current = question;
|
|
@@ -94,7 +98,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
94
98
|
const resetState = useCallback(() => {
|
|
95
99
|
setCurrentQuestion('');
|
|
96
100
|
setDisplayedQuestions(suggestedQuestions.data);
|
|
97
|
-
|
|
101
|
+
// A stored conversation outlives the modal: it is there again when the modal reopens.
|
|
102
|
+
if (!persistedRef.current)
|
|
103
|
+
setConversationHistory([]);
|
|
98
104
|
prevAnswerDataRef.current = null;
|
|
99
105
|
showsFollowUpsRef.current = false;
|
|
100
106
|
}, [suggestedQuestions.data]);
|
|
@@ -109,6 +115,19 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
109
115
|
prevAnswerDataRef.current = null;
|
|
110
116
|
showsFollowUpsRef.current = false;
|
|
111
117
|
}, [itemId]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
// Runs after the itemId reset above, so another product's stored conversation replaces the empty one.
|
|
120
|
+
if (!persisted || prevPersistedKeyRef.current === persisted.key)
|
|
121
|
+
return;
|
|
122
|
+
prevPersistedKeyRef.current = persisted.key;
|
|
123
|
+
// The live answer belongs to the conversation being replaced: left in place, the restored last
|
|
124
|
+
// turn would show its products and credit its feedback.
|
|
125
|
+
answersRef.current.clear();
|
|
126
|
+
const restored = renumber(persisted.history);
|
|
127
|
+
entryIdRef.current = restored.length;
|
|
128
|
+
setConversationHistory(restored);
|
|
129
|
+
setCurrentQuestion('');
|
|
130
|
+
}, [persisted]);
|
|
112
131
|
useEffect(() => {
|
|
113
132
|
// The ref, not `answers.data`, which outlives the item it answered.
|
|
114
133
|
if (showsFollowUpsRef.current)
|
|
@@ -140,7 +159,9 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
140
159
|
const qnaResultId = answers.data?.qna_result_id;
|
|
141
160
|
if (isConversation) {
|
|
142
161
|
setConversationHistory((prev) => {
|
|
143
|
-
|
|
162
|
+
// Only a question still waiting is answered: after a switch of product or shopper, a late
|
|
163
|
+
// answer must not overwrite a settled turn of the conversation that replaced it.
|
|
164
|
+
if (prev.length === 0 || prev[prev.length - 1].answer !== '')
|
|
144
165
|
return prev;
|
|
145
166
|
const updated = [...prev];
|
|
146
167
|
updated[updated.length - 1] = {
|
|
@@ -151,6 +172,7 @@ export default function useConversation({ pia, itemId, isConversation, callbacks
|
|
|
151
172
|
qnaResultId,
|
|
152
173
|
};
|
|
153
174
|
callbacksRef.current?.onAnswer?.(updated, contextRef.current);
|
|
175
|
+
persistedRef.current?.save(updated);
|
|
154
176
|
return updated;
|
|
155
177
|
});
|
|
156
178
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { isGuest, loadConversation, moveGuestConversations, saveConversation, } from '../utils/conversationStorage';
|
|
3
|
+
function loadIfEnabled({ enabled, apiKey, userId, itemId, threadIdProp, }) {
|
|
4
|
+
if (!enabled)
|
|
5
|
+
return { threadId: '', history: [] };
|
|
6
|
+
const stored = loadConversation({ apiKey, userId }, itemId);
|
|
7
|
+
if (stored && (!threadIdProp || stored.threadId === threadIdProp)) {
|
|
8
|
+
return { threadId: stored.threadId, history: stored.entries };
|
|
9
|
+
}
|
|
10
|
+
// A product with nothing stored starts its own thread, so returning to another product resumes
|
|
11
|
+
// a thread that only ever heard about that product.
|
|
12
|
+
return { threadId: threadIdProp || crypto.randomUUID(), history: [] };
|
|
13
|
+
}
|
|
14
|
+
/** The stored conversation for this product and shopper, or `undefined` while persistence is off. */
|
|
15
|
+
export default function usePersistedConversation({ enabled, apiKey, userId: userIdProp, itemId, threadId: threadIdProp, }) {
|
|
16
|
+
const userId = isGuest(userIdProp) ? undefined : String(userIdProp);
|
|
17
|
+
const userIdOmitted = userIdProp === undefined;
|
|
18
|
+
// An omitted id stores a signed-in shopper as a guest, so say so rather than fail silently.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!enabled || !userIdOmitted)
|
|
21
|
+
return;
|
|
22
|
+
console.warn('[CioPia] persistConversation is on but no userId was passed, so conversations are stored as a guest. Pass the signed-in shopper id, or null for a guest.');
|
|
23
|
+
}, [enabled, userIdOmitted]);
|
|
24
|
+
const target = { enabled, apiKey, userId, itemId, threadIdProp };
|
|
25
|
+
const [shown, setShown] = useState(() => ({
|
|
26
|
+
...target,
|
|
27
|
+
key: 0,
|
|
28
|
+
loggedIn: false,
|
|
29
|
+
...loadIfEnabled(target),
|
|
30
|
+
}));
|
|
31
|
+
// Adjusted during render, not in an effect: React re-renders before committing, so the first
|
|
32
|
+
// request after a switch of product or shopper already goes out on the new thread. See
|
|
33
|
+
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
|
34
|
+
let current = shown;
|
|
35
|
+
if (shown.enabled !== enabled ||
|
|
36
|
+
shown.apiKey !== apiKey ||
|
|
37
|
+
shown.userId !== userId ||
|
|
38
|
+
shown.itemId !== itemId ||
|
|
39
|
+
shown.threadIdProp !== threadIdProp) {
|
|
40
|
+
const loggedIn = enabled &&
|
|
41
|
+
shown.enabled &&
|
|
42
|
+
shown.apiKey === apiKey &&
|
|
43
|
+
shown.userId === undefined &&
|
|
44
|
+
userId !== undefined;
|
|
45
|
+
// A login continues the guest's conversation as the shopper's own. With none to carry over,
|
|
46
|
+
// the shopper's own stored conversation is shown, rather than replaced by an empty one.
|
|
47
|
+
const keepsConversation = loggedIn &&
|
|
48
|
+
shown.itemId === itemId &&
|
|
49
|
+
shown.threadIdProp === threadIdProp &&
|
|
50
|
+
loadConversation({ apiKey }, itemId) !== undefined;
|
|
51
|
+
const conversation = keepsConversation
|
|
52
|
+
? { key: shown.key, threadId: shown.threadId, history: shown.history }
|
|
53
|
+
: { key: shown.key + 1, ...loadIfEnabled(target) };
|
|
54
|
+
current = { ...target, loggedIn, ...conversation };
|
|
55
|
+
setShown(current);
|
|
56
|
+
}
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (shown.loggedIn && shown.userId)
|
|
59
|
+
moveGuestConversations(shown.apiKey, shown.userId);
|
|
60
|
+
}, [shown]);
|
|
61
|
+
const shownRef = useRef(shown);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
shownRef.current = shown;
|
|
64
|
+
}, [shown]);
|
|
65
|
+
const save = useCallback((history) => {
|
|
66
|
+
const latest = shownRef.current;
|
|
67
|
+
if (!latest.enabled)
|
|
68
|
+
return;
|
|
69
|
+
saveConversation({ apiKey: latest.apiKey, userId: latest.userId }, latest.itemId, latest.threadId, history);
|
|
70
|
+
}, []);
|
|
71
|
+
const { key, threadId, history } = current;
|
|
72
|
+
return useMemo(() => (current.enabled ? { key, threadId, history, save } : undefined), [current.enabled, key, threadId, history, save]);
|
|
73
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import ConstructorIOClient from '@constructor-io/constructorio-client-javascript';
|
|
3
3
|
import version from '../version';
|
|
4
|
-
export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, }) {
|
|
4
|
+
export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, userId, }) {
|
|
5
5
|
const [generatedThreadId] = useState(() => crypto.randomUUID());
|
|
6
6
|
const threadId = providedThreadId || generatedThreadId;
|
|
7
7
|
const serializedTestCells = useMemo(() => JSON.stringify(testCells ?? null), [testCells]);
|
|
@@ -17,6 +17,13 @@ export default function usePiaClient({ apiKey, threadId: providedThreadId, cioCl
|
|
|
17
17
|
// testCells is compared by serializedTestCells so an inline object does not rebuild the client
|
|
18
18
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
19
19
|
}, [apiKey, providedClient, serializedTestCells]);
|
|
20
|
+
// Set rather than passed to the constructor, so a login does not rebuild the client.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (providedClient || userId === undefined)
|
|
23
|
+
return;
|
|
24
|
+
// The typings require `apiKey`; the client merges a partial update.
|
|
25
|
+
client.setClientOptions({ userId: userId || undefined });
|
|
26
|
+
}, [client, providedClient, userId]);
|
|
20
27
|
// A caller's client owns its own options, so say so rather than dropping these silently.
|
|
21
28
|
useEffect(() => {
|
|
22
29
|
if (!providedClient || !testCells || Object.keys(testCells).length === 0)
|
package/lib/mjs/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import CioPia from './components/CioPia';
|
|
|
3
3
|
export { default as CioPia } from './components/CioPia';
|
|
4
4
|
// Utilities
|
|
5
5
|
export { sanitizeHtml, renderMarkdown } from './utils/contentTransformers';
|
|
6
|
+
// Persistence
|
|
7
|
+
export { clearPersistedConversations } from './utils/conversationStorage';
|
|
6
8
|
// Errors
|
|
7
9
|
export { AgentRequestError } from './errors';
|
|
8
10
|
// Default
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const KEY_PREFIX = 'cio-pia:chat:v1';
|
|
2
|
+
const STORED_VERSION = 1;
|
|
3
|
+
/** Matches how long the agent remembers a thread. */
|
|
4
|
+
export const PERSISTENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export const isGuest = (userId) => userId == null || userId === '';
|
|
6
|
+
export function isRestorableEntry(entry) {
|
|
7
|
+
if (typeof entry !== 'object' || entry === null)
|
|
8
|
+
return false;
|
|
9
|
+
const { question, answer, items } = entry;
|
|
10
|
+
return (typeof question === 'string' &&
|
|
11
|
+
typeof answer === 'string' &&
|
|
12
|
+
(items === undefined || items === null || Array.isArray(items)));
|
|
13
|
+
}
|
|
14
|
+
function isPersistedConversation(value) {
|
|
15
|
+
if (typeof value !== 'object' || value === null)
|
|
16
|
+
return false;
|
|
17
|
+
const { threadId, entries, updatedAt } = value;
|
|
18
|
+
return (typeof threadId === 'string' &&
|
|
19
|
+
threadId !== '' &&
|
|
20
|
+
typeof updatedAt === 'number' &&
|
|
21
|
+
Array.isArray(entries) &&
|
|
22
|
+
entries.every(isRestorableEntry));
|
|
23
|
+
}
|
|
24
|
+
function storageKeyFor({ apiKey, userId }) {
|
|
25
|
+
const parts = isGuest(userId) ? [apiKey] : [apiKey, String(userId)];
|
|
26
|
+
return [KEY_PREFIX, ...parts.map(encodeURIComponent)].join(':');
|
|
27
|
+
}
|
|
28
|
+
// A guest's conversations end with the tab, so the next person at that browser never sees them.
|
|
29
|
+
function storageFor({ userId }) {
|
|
30
|
+
try {
|
|
31
|
+
if (typeof window === 'undefined')
|
|
32
|
+
return null;
|
|
33
|
+
return isGuest(userId) ? window.sessionStorage : window.localStorage;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readItems(storage, key) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(storage.getItem(key) ?? 'null');
|
|
42
|
+
if (parsed?.version !== STORED_VERSION || typeof parsed.items !== 'object' || !parsed.items) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
const cutoff = Date.now() - PERSISTENCE_TTL_MS;
|
|
46
|
+
return Object.fromEntries(Object.entries(parsed.items).filter((pair) => isPersistedConversation(pair[1]) && pair[1].updatedAt >= cutoff));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function tryWrite(storage, key, items) {
|
|
53
|
+
try {
|
|
54
|
+
if (Object.keys(items).length === 0)
|
|
55
|
+
storage.removeItem(key);
|
|
56
|
+
else
|
|
57
|
+
storage.setItem(key, JSON.stringify({ version: STORED_VERSION, items }));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// When storage is full, shed other products' conversations oldest first, then this one's oldest
|
|
65
|
+
// turns. If not even one turn fits, this conversation is left out rather than stored half-written.
|
|
66
|
+
function writeFitting(storage, key, items, itemId) {
|
|
67
|
+
if (tryWrite(storage, key, items))
|
|
68
|
+
return;
|
|
69
|
+
const { [itemId]: saved, ...others } = items;
|
|
70
|
+
const oldestFirst = Object.keys(others).sort((a, b) => others[a].updatedAt - others[b].updatedAt);
|
|
71
|
+
const fitsAfterEviction = oldestFirst.some((_, index) => {
|
|
72
|
+
const kept = Object.fromEntries(oldestFirst.slice(index + 1).map((id) => [id, others[id]]));
|
|
73
|
+
return tryWrite(storage, key, { ...kept, [itemId]: saved });
|
|
74
|
+
});
|
|
75
|
+
if (fitsAfterEviction)
|
|
76
|
+
return;
|
|
77
|
+
const fitsTrimmed = saved.entries
|
|
78
|
+
.slice(1)
|
|
79
|
+
.some((_, i) => tryWrite(storage, key, { [itemId]: { ...saved, entries: saved.entries.slice(i + 1) } }));
|
|
80
|
+
if (fitsTrimmed)
|
|
81
|
+
return;
|
|
82
|
+
tryWrite(storage, key, others);
|
|
83
|
+
}
|
|
84
|
+
export function loadConversation(owner, itemId) {
|
|
85
|
+
const storage = storageFor(owner);
|
|
86
|
+
return storage ? readItems(storage, storageKeyFor(owner))[itemId] : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Stores the answered turns of one product's conversation. A turn still waiting on its answer is left out. */
|
|
89
|
+
export function saveConversation(owner, itemId, threadId, history) {
|
|
90
|
+
const entries = history.filter((entry) => entry.answer !== '');
|
|
91
|
+
const storage = storageFor(owner);
|
|
92
|
+
if (!storage || entries.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
const key = storageKeyFor(owner);
|
|
95
|
+
const items = readItems(storage, key);
|
|
96
|
+
writeFitting(storage, key, { ...items, [itemId]: { threadId, entries, updatedAt: Date.now() } }, itemId);
|
|
97
|
+
}
|
|
98
|
+
/** On login: every guest conversation moves into the shopper's store, so none is left for the next guest. */
|
|
99
|
+
export function moveGuestConversations(apiKey, userId) {
|
|
100
|
+
const guest = { apiKey };
|
|
101
|
+
const shopper = { apiKey, userId };
|
|
102
|
+
const guestStorage = storageFor(guest);
|
|
103
|
+
const shopperStorage = storageFor(shopper);
|
|
104
|
+
if (!guestStorage || !shopperStorage)
|
|
105
|
+
return;
|
|
106
|
+
const guestKey = storageKeyFor(guest);
|
|
107
|
+
const moved = readItems(guestStorage, guestKey);
|
|
108
|
+
if (Object.keys(moved).length === 0)
|
|
109
|
+
return;
|
|
110
|
+
const shopperKey = storageKeyFor(shopper);
|
|
111
|
+
const merged = readItems(shopperStorage, shopperKey);
|
|
112
|
+
Object.entries(moved).forEach(([itemId, conversation]) => {
|
|
113
|
+
if ((merged[itemId]?.updatedAt ?? -Infinity) <= conversation.updatedAt) {
|
|
114
|
+
merged[itemId] = conversation;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (tryWrite(shopperStorage, shopperKey, merged))
|
|
118
|
+
tryWrite(guestStorage, guestKey, {});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Deletes every stored conversation of one shopper, or of the current tab's guest when `userId` is
|
|
122
|
+
* omitted. A widget already showing one keeps it on screen until its `itemId` or `userId` changes.
|
|
123
|
+
*/
|
|
124
|
+
export function clearPersistedConversations({ apiKey, userId, }) {
|
|
125
|
+
const owner = { apiKey, userId };
|
|
126
|
+
const storage = storageFor(owner);
|
|
127
|
+
if (storage)
|
|
128
|
+
tryWrite(storage, storageKeyFor(owner), {});
|
|
129
|
+
}
|
package/lib/mjs/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '2.
|
|
1
|
+
export default '2.17.0';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { IncludeComponentOverrides, IncludeRenderProps } from '@constructor-io/constructorio-ui-components';
|
|
3
|
-
import { CioPiaRenderProps, CioPiaComponentOverrides, Callbacks, CioPiaAbTest, CioPiaDisplayConfigs, CioPiaTrackingConfigs, Translations, SuggestedQuestionsParameters, AnswerRequestParameters, RecsPodParameters, Formatters, ConversationEntry, ProductCardDisplayProps } from '../../types';
|
|
3
|
+
import { CioPiaRenderProps, CioPiaComponentOverrides, Callbacks, CioPiaAbTest, CioPiaDisplayConfigs, CioPiaTrackingConfigs, Translations, SuggestedQuestionsParameters, AnswerRequestParameters, RecsPodParameters, Formatters, ConversationEntry, PersistConversationOptions, ProductCardDisplayProps } from '../../types';
|
|
4
4
|
import type { CioClient } from '../../hooks/usePiaClient';
|
|
5
5
|
/**
|
|
6
6
|
* A single checkout entry point rendered inside the PIA `default` mode.
|
|
@@ -56,6 +56,21 @@ export interface CioPiaProps extends IncludeRenderProps<CioPiaRenderProps>, Incl
|
|
|
56
56
|
* `mode: 'conversation'` and `type: 'modal'` only; the modal shows them once it opens.
|
|
57
57
|
*/
|
|
58
58
|
initialConversationHistory?: ConversationEntry[];
|
|
59
|
+
/**
|
|
60
|
+
* Off by default. `{ enabled: true }` keeps each product's conversation in the browser, so it is
|
|
61
|
+
* still there after a reload or on returning to the product. Pass `userId` with it. An explicit
|
|
62
|
+
* `initialConversationHistory` is shown instead of the stored one, and with an explicit
|
|
63
|
+
* `threadId` only a conversation stored on that thread is restored. Used by
|
|
64
|
+
* `mode: 'conversation'` and `type: 'modal'` only.
|
|
65
|
+
*/
|
|
66
|
+
persistConversation?: PersistConversationOptions;
|
|
67
|
+
/**
|
|
68
|
+
* Whose conversations these are: the signed-in shopper's stable, non-personal id, or `null` for a
|
|
69
|
+
* guest. Change it on login and logout. A login carries the guest's conversations over to the
|
|
70
|
+
* shopper; a logout or another id switches to that history. Without your own `cioClient`, it is
|
|
71
|
+
* also set on the client the widget creates.
|
|
72
|
+
*/
|
|
73
|
+
userId?: string | null;
|
|
59
74
|
/** Optional variation ID for the product. */
|
|
60
75
|
variationId?: string;
|
|
61
76
|
/** Optional Constructor.io client instance. If not provided, one will be created internally. */
|
|
@@ -15,5 +15,7 @@ export interface UseAnswerResultsReturn {
|
|
|
15
15
|
isLoading: boolean;
|
|
16
16
|
error: Error | null;
|
|
17
17
|
getAnswer: (question: string) => void;
|
|
18
|
+
/** Forgets the answer, and drops any response still in flight, when the conversation is replaced. */
|
|
19
|
+
clear: () => void;
|
|
18
20
|
}
|
|
19
21
|
export default function useAnswerResults({ itemId, variationId, threadId, cioClient, parameters, formatImageUrl, }: UseAnswerResultsProps): UseAnswerResultsReturn;
|
|
@@ -34,6 +34,8 @@ export interface UseCioPiaProps {
|
|
|
34
34
|
formatImageUrl?: Formatters['formatImageUrl'];
|
|
35
35
|
/** Test cells to attach to tracking events. Ignored when `cioClient` is supplied. */
|
|
36
36
|
testCells?: Record<string, string>;
|
|
37
|
+
/** The shopper's id, set on the client the hook creates. `null` for a guest. */
|
|
38
|
+
userId?: string | null;
|
|
37
39
|
}
|
|
38
40
|
export interface UseCioPiaReturn {
|
|
39
41
|
cioClient: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Callbacks, ConversationEntry, FeedbackType, PiaCallbackContext, Question, Item } from '../types';
|
|
2
2
|
import { UseCioPiaReturn } from './useCioPia';
|
|
3
3
|
import { UseTrackingReturn } from './useTracking';
|
|
4
|
+
import { PersistedConversationState } from './usePersistedConversation';
|
|
4
5
|
export interface UseConversationProps {
|
|
5
6
|
pia: UseCioPiaReturn;
|
|
6
7
|
itemId: string;
|
|
@@ -8,6 +9,8 @@ export interface UseConversationProps {
|
|
|
8
9
|
callbacks?: Callbacks;
|
|
9
10
|
tracking?: UseTrackingReturn;
|
|
10
11
|
initialConversationHistory?: ConversationEntry[];
|
|
12
|
+
/** The stored conversation to show and save to, when `persistConversation` is on. */
|
|
13
|
+
persisted?: PersistedConversationState;
|
|
11
14
|
}
|
|
12
15
|
export interface UseConversationReturn {
|
|
13
16
|
currentQuestion: string;
|
|
@@ -24,4 +27,4 @@ export interface UseConversationReturn {
|
|
|
24
27
|
handleFeedback: (type: FeedbackType) => void;
|
|
25
28
|
resetState: () => void;
|
|
26
29
|
}
|
|
27
|
-
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, }: UseConversationProps): UseConversationReturn;
|
|
30
|
+
export default function useConversation({ pia, itemId, isConversation, callbacks, tracking, initialConversationHistory, persisted, }: UseConversationProps): UseConversationReturn;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ConversationEntry } from '../types';
|
|
2
|
+
export interface UsePersistedConversationProps {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
userId?: string | null;
|
|
6
|
+
itemId: string;
|
|
7
|
+
/** A caller's thread: only a stored conversation on that same thread is restored. */
|
|
8
|
+
threadId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PersistedConversationState {
|
|
11
|
+
/** Changes whenever the conversation on screen is replaced by another stored one. */
|
|
12
|
+
key: number;
|
|
13
|
+
threadId: string;
|
|
14
|
+
history: ConversationEntry[];
|
|
15
|
+
save: (history: ConversationEntry[]) => void;
|
|
16
|
+
}
|
|
17
|
+
/** The stored conversation for this product and shopper, or `undefined` while persistence is off. */
|
|
18
|
+
export default function usePersistedConversation({ enabled, apiKey, userId: userIdProp, itemId, threadId: threadIdProp, }: UsePersistedConversationProps): PersistedConversationState | undefined;
|
|
@@ -5,9 +5,11 @@ export interface UsePiaClientProps {
|
|
|
5
5
|
threadId?: string;
|
|
6
6
|
cioClient?: CioClient;
|
|
7
7
|
testCells?: Record<string, string>;
|
|
8
|
+
/** Set on the client the hook creates; `null` or empty for a guest. Left alone when omitted. */
|
|
9
|
+
userId?: string | null;
|
|
8
10
|
}
|
|
9
11
|
export interface UsePiaClientReturn {
|
|
10
12
|
cioClient: CioClient;
|
|
11
13
|
threadId: string;
|
|
12
14
|
}
|
|
13
|
-
export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, }: UsePiaClientProps): UsePiaClientReturn;
|
|
15
|
+
export default function usePiaClient({ apiKey, threadId: providedThreadId, cioClient: providedClient, testCells, userId, }: UsePiaClientProps): UsePiaClientReturn;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default as CioPia } from './components/CioPia';
|
|
|
3
3
|
export type { CioPiaProps, CheckoutTrigger } from './components/CioPia';
|
|
4
4
|
export { sanitizeHtml, renderMarkdown } from './utils/contentTransformers';
|
|
5
5
|
export type { RenderMarkdownOptions, SanitizeOptions } from './utils/contentTransformers';
|
|
6
|
+
export { clearPersistedConversations } from './utils/conversationStorage';
|
|
6
7
|
export { AgentRequestError } from './errors';
|
|
7
8
|
export * from './types';
|
|
8
9
|
export default CioPia;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -179,6 +179,20 @@ export interface ConversationEntry {
|
|
|
179
179
|
/** Identifies the answer for feedback and product click tracking. */
|
|
180
180
|
qnaResultId?: string;
|
|
181
181
|
}
|
|
182
|
+
/** How conversations are kept in the browser. */
|
|
183
|
+
export interface PersistConversationOptions {
|
|
184
|
+
/**
|
|
185
|
+
* Keep each product's conversation across page loads: a signed-in shopper's in `localStorage`
|
|
186
|
+
* for 7 days, keyed by api key and user id; a guest's in `sessionStorage`, ending with the tab.
|
|
187
|
+
*/
|
|
188
|
+
enabled: boolean;
|
|
189
|
+
}
|
|
190
|
+
/** Whose stored conversations `clearPersistedConversations` deletes; mirror what the widget was given. */
|
|
191
|
+
export interface ClearPersistedConversationsOptions {
|
|
192
|
+
apiKey: string;
|
|
193
|
+
/** The shopper whose history to delete. Omit or pass `null` for the current tab's guest history. */
|
|
194
|
+
userId?: string | null;
|
|
195
|
+
}
|
|
182
196
|
/** Which kind of recommendations to fetch. */
|
|
183
197
|
export type RecsStrategy = 'complementary_items' | 'alternative_items' | 'bestsellers' | 'bundles' | 'buy_it_again' | 'recently_viewed_items' | 'visually_similar_items';
|
|
184
198
|
/** A prompt line plus the short labels the shopper can pick from to narrow the results. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ClearPersistedConversationsOptions, ConversationEntry } from '../types';
|
|
2
|
+
/** Matches how long the agent remembers a thread. */
|
|
3
|
+
export declare const PERSISTENCE_TTL_MS: number;
|
|
4
|
+
export interface PersistedConversation {
|
|
5
|
+
threadId: string;
|
|
6
|
+
entries: ConversationEntry[];
|
|
7
|
+
updatedAt: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const isGuest: (userId: string | null | undefined) => boolean;
|
|
10
|
+
export declare function isRestorableEntry(entry: unknown): entry is ConversationEntry;
|
|
11
|
+
export declare function loadConversation(owner: ClearPersistedConversationsOptions, itemId: string): PersistedConversation | undefined;
|
|
12
|
+
/** Stores the answered turns of one product's conversation. A turn still waiting on its answer is left out. */
|
|
13
|
+
export declare function saveConversation(owner: ClearPersistedConversationsOptions, itemId: string, threadId: string, history: ConversationEntry[]): void;
|
|
14
|
+
/** On login: every guest conversation moves into the shopper's store, so none is left for the next guest. */
|
|
15
|
+
export declare function moveGuestConversations(apiKey: string, userId: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Deletes every stored conversation of one shopper, or of the current tab's guest when `userId` is
|
|
18
|
+
* omitted. A widget already showing one keeps it on screen until its `itemId` or `userId` changes.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clearPersistedConversations({ apiKey, userId, }: ClearPersistedConversationsOptions): void;
|
package/lib/types/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "2.
|
|
1
|
+
declare const _default: "2.17.0";
|
|
2
2
|
export default _default;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructor-io/constructorio-ui-pia",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Constructor.io AI Product Insights Agent UI library for web applications",
|
|
5
5
|
"author": "Constructor.io Corporation",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
"require": "./dist/constructorio-ui-pia-bundled.js"
|
|
37
37
|
},
|
|
38
38
|
".": {
|
|
39
|
+
"types": "./lib/types/index.d.ts",
|
|
39
40
|
"import": "./lib/mjs/index.js",
|
|
40
41
|
"require": "./lib/cjs/index.js"
|
|
41
42
|
},
|
|
42
43
|
"./cjs": {
|
|
44
|
+
"types": "./lib/types/index.d.ts",
|
|
43
45
|
"import": "./lib/cjs/index.js",
|
|
44
46
|
"require": "./lib/cjs/index.js"
|
|
45
47
|
}
|