@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
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
2
|
+
import { findRekeyedThread, getTabId, foreignStreamRemainingMs, isInFlight, mergeMessages, isLocalThreadId, moveThreads, normalizeHydratedMessages, saveThreadAndRetireStale, } from '../../utils/chatThreads';
|
|
3
|
+
import { adoptSession, adoptStoredChat, forkSession, isOwnMessage, prepareSnapshot, resetConversation, } from '../../utils/chatSession';
|
|
4
|
+
import useIsMounted from '../useIsMounted';
|
|
5
|
+
import useLatest from '../useLatest';
|
|
6
|
+
import useWriteQueue from './useWriteQueue';
|
|
7
|
+
import useThreadList from './useThreadList';
|
|
8
|
+
import useForeignStream from './useForeignStream';
|
|
9
|
+
import { continueInBackground, moveBackgroundTurns, resumeBackgroundTurn, subscribeBackgroundTurns, } from './backgroundTurns';
|
|
10
|
+
const hasUnsavedWork = (session) => session.isStreaming || session.dirty || session.orphanIds.length > 0;
|
|
11
|
+
/** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
|
|
12
|
+
export default function useChatPersistence(params) {
|
|
13
|
+
const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, } = params;
|
|
14
|
+
if (store)
|
|
15
|
+
getTabId();
|
|
16
|
+
const [isHydrating, setIsHydrating] = useState(Boolean(store));
|
|
17
|
+
const [activeThreadId, setActiveThreadId] = useState(null);
|
|
18
|
+
const storeRef = useLatest(store);
|
|
19
|
+
const messagesRef = useLatest(messages);
|
|
20
|
+
const mountedRef = useIsMounted();
|
|
21
|
+
const { enqueue: enqueueWrite, reset: resetWrites } = useWriteQueue();
|
|
22
|
+
// Async writes not yet committed; the page may go away before a queued one gets its lock.
|
|
23
|
+
const pendingWritesRef = useRef(0);
|
|
24
|
+
// Another tab changed the thread while this one streamed; looked at again once the turn is saved.
|
|
25
|
+
const missedSyncRef = useRef(false);
|
|
26
|
+
const { threads, setThreads, refreshThreads, invalidate: invalidateThreads, } = useThreadList(storeRef);
|
|
27
|
+
const { foreignInFlight, setForeignInFlight, watch: watchForeign, stop: stopForeign, clearTimer: clearForeignTimer, } = useForeignStream(session);
|
|
28
|
+
// Reset during render so the previous store's conversation never paints under the new one.
|
|
29
|
+
// A login keeps the guest conversation on screen instead and moves it into the shopper's store.
|
|
30
|
+
const [rendered, setRendered] = useState({ store, scope, index });
|
|
31
|
+
const carryOverFromRef = useRef(undefined);
|
|
32
|
+
// Every guest thread moves into the shopper's store on login, so none is left for the next guest.
|
|
33
|
+
const migrateFromRef = useRef(undefined);
|
|
34
|
+
const settleIntoRef = useRef(undefined);
|
|
35
|
+
if (rendered.store !== store) {
|
|
36
|
+
const login = rendered.store &&
|
|
37
|
+
store &&
|
|
38
|
+
rendered.index === index &&
|
|
39
|
+
rendered.scope === 'guest' &&
|
|
40
|
+
scope === 'user';
|
|
41
|
+
const carryOver = login && messages.length > 0;
|
|
42
|
+
setRendered({ store, scope, index });
|
|
43
|
+
setThreads([]);
|
|
44
|
+
if (login)
|
|
45
|
+
migrateFromRef.current = rendered.store;
|
|
46
|
+
if (carryOver) {
|
|
47
|
+
carryOverFromRef.current = rendered.store;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// An unfinished turn still belongs to the previous store; the effect below writes it there.
|
|
51
|
+
if (rendered.store && messages.length > 0 && hasUnsavedWork(session)) {
|
|
52
|
+
settleIntoRef.current = { store: rendered.store, messages };
|
|
53
|
+
}
|
|
54
|
+
setMessages([]);
|
|
55
|
+
setActiveThreadId(null);
|
|
56
|
+
setForeignInFlight(false);
|
|
57
|
+
setIsStreaming(false);
|
|
58
|
+
setIsHydrating(Boolean(store));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Puts a stored conversation on screen and makes it the one this tab continues. */
|
|
62
|
+
const showStoredChat = useCallback((chat) => {
|
|
63
|
+
adoptStoredChat(session, chat);
|
|
64
|
+
setActiveThreadId(chat.threadId);
|
|
65
|
+
const remainingMs = foreignStreamRemainingMs(chat);
|
|
66
|
+
if (remainingMs === 0) {
|
|
67
|
+
stopForeign();
|
|
68
|
+
setMessages(normalizeHydratedMessages(chat.messages));
|
|
69
|
+
// Write the settled snapshot back so the list stops reporting it in flight.
|
|
70
|
+
if (isInFlight(chat.messages))
|
|
71
|
+
session.dirty = true;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setMessages(chat.messages);
|
|
75
|
+
watchForeign(remainingMs, () => {
|
|
76
|
+
if (!mountedRef.current || session.storageThreadId !== chat.threadId)
|
|
77
|
+
return;
|
|
78
|
+
if (session.isStreaming)
|
|
79
|
+
return;
|
|
80
|
+
session.dirty = true;
|
|
81
|
+
setMessages((prev) => normalizeHydratedMessages(prev));
|
|
82
|
+
});
|
|
83
|
+
}, [session, stopForeign, watchForeign, setMessages, mountedRef]);
|
|
84
|
+
/** Writes the conversation as it is now; `settle` finishes an in-flight answer, `sync` skips awaiting. */
|
|
85
|
+
const persistNow = useCallback((mode) => {
|
|
86
|
+
const { current } = storeRef;
|
|
87
|
+
if (!current || messagesRef.current.length === 0)
|
|
88
|
+
return undefined;
|
|
89
|
+
const toSave = mode?.settle
|
|
90
|
+
? normalizeHydratedMessages(messagesRef.current)
|
|
91
|
+
: messagesRef.current;
|
|
92
|
+
const { snapshot, staleIds } = prepareSnapshot(session, toSave);
|
|
93
|
+
if (mode?.sync && current.saveThreadSync) {
|
|
94
|
+
current.saveThreadSync(snapshot, staleIds);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (mountedRef.current)
|
|
98
|
+
setActiveThreadId(snapshot.threadId);
|
|
99
|
+
pendingWritesRef.current += 1;
|
|
100
|
+
return enqueueWrite(async () => {
|
|
101
|
+
const { saved, leftover } = await saveThreadAndRetireStale(current, snapshot, staleIds).finally(() => {
|
|
102
|
+
pendingWritesRef.current -= 1;
|
|
103
|
+
});
|
|
104
|
+
// Leftovers of a conversation the user has since left stay stored: they may be its only copy.
|
|
105
|
+
const sameConversation = storeRef.current === current && session.storageThreadId === snapshot.threadId;
|
|
106
|
+
if (!sameConversation)
|
|
107
|
+
return;
|
|
108
|
+
if (leftover.length > 0) {
|
|
109
|
+
session.orphanIds = Array.from(new Set([...session.orphanIds, ...leftover]));
|
|
110
|
+
}
|
|
111
|
+
// Not stored and no newer snapshot on its way: pagehide or the next save must write it.
|
|
112
|
+
if (!saved && session.lastSyncedAt === snapshot.updatedAt)
|
|
113
|
+
session.dirty = true;
|
|
114
|
+
});
|
|
115
|
+
}, [session, storeRef, messagesRef, enqueueWrite, mountedRef]);
|
|
116
|
+
const onStreamStart = useCallback(() => {
|
|
117
|
+
persistNow()?.then(refreshThreads);
|
|
118
|
+
}, [persistNow, refreshThreads]);
|
|
119
|
+
/** Where this tab's answer goes while its conversation is on screen. */
|
|
120
|
+
const screenOwner = useCallback((turn) => ({
|
|
121
|
+
setMessages,
|
|
122
|
+
onStart: (threadId) => {
|
|
123
|
+
if (threadId)
|
|
124
|
+
session.serverThreadId = threadId;
|
|
125
|
+
onStreamStart();
|
|
126
|
+
},
|
|
127
|
+
onDone: () => {
|
|
128
|
+
// A turn cancelled by a reset, or handed to the background, is accounted for elsewhere.
|
|
129
|
+
if (turnRef.current !== turn)
|
|
130
|
+
return;
|
|
131
|
+
turnRef.current = null;
|
|
132
|
+
session.dirty = true;
|
|
133
|
+
session.isStreaming = false;
|
|
134
|
+
setIsStreaming(false);
|
|
135
|
+
},
|
|
136
|
+
}), [session, setMessages, onStreamStart, turnRef, setIsStreaming]);
|
|
137
|
+
/** Lets this tab's answer finish in the background, so leaving its conversation does not stop it. */
|
|
138
|
+
const detachTurn = useCallback(() => {
|
|
139
|
+
const turn = turnRef.current;
|
|
140
|
+
const { current } = storeRef;
|
|
141
|
+
const shown = messagesRef.current;
|
|
142
|
+
if (!turn || !current || !session.isStreaming)
|
|
143
|
+
return false;
|
|
144
|
+
if (!shown.some((m) => m.id === turn.assistantId))
|
|
145
|
+
return false;
|
|
146
|
+
turnRef.current = null;
|
|
147
|
+
continueInBackground(turn, {
|
|
148
|
+
store: current,
|
|
149
|
+
session: forkSession(session),
|
|
150
|
+
messages: shown.map((m) => (m.id === turn.assistantId ? turn.assistant : m)),
|
|
151
|
+
after: enqueueWrite(async () => { }),
|
|
152
|
+
});
|
|
153
|
+
session.isStreaming = false;
|
|
154
|
+
return true;
|
|
155
|
+
}, [session, storeRef, messagesRef, turnRef, enqueueWrite]);
|
|
156
|
+
/** Shows a conversation whose answer went on in the background; an unfinished one streams on here. */
|
|
157
|
+
const showBackgroundTurn = useCallback((bg) => {
|
|
158
|
+
stopForeign();
|
|
159
|
+
adoptSession(session, bg.session, bg.messages);
|
|
160
|
+
enqueueWrite(() => bg.writes);
|
|
161
|
+
setActiveThreadId(session.storageThreadId);
|
|
162
|
+
setMessages(bg.messages);
|
|
163
|
+
if (!bg.streaming) {
|
|
164
|
+
if (!bg.saved)
|
|
165
|
+
session.dirty = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
turnRef.current = bg.turn;
|
|
169
|
+
session.isStreaming = true;
|
|
170
|
+
setIsStreaming(true);
|
|
171
|
+
}, [session, stopForeign, enqueueWrite, setMessages, turnRef, setIsStreaming]);
|
|
172
|
+
const settleAndPersist = useCallback(() => {
|
|
173
|
+
if (hasUnsavedWork(session))
|
|
174
|
+
return persistNow({ settle: true });
|
|
175
|
+
return undefined;
|
|
176
|
+
}, [session, persistNow]);
|
|
177
|
+
/** Write the settled conversation without awaiting anything: the page is going away. */
|
|
178
|
+
const flushBeforeUnload = useCallback(() => {
|
|
179
|
+
if (!hasUnsavedWork(session) && pendingWritesRef.current === 0)
|
|
180
|
+
return;
|
|
181
|
+
if (storeRef.current?.saveThreadSync)
|
|
182
|
+
session.dirty = false;
|
|
183
|
+
persistNow({ settle: true, sync: true });
|
|
184
|
+
}, [session, storeRef, persistNow]);
|
|
185
|
+
/** Forget the conversation on screen; returns every id it is stored under. */
|
|
186
|
+
const forgetConversation = useCallback(() => {
|
|
187
|
+
stopForeign();
|
|
188
|
+
cancelStream();
|
|
189
|
+
const { orphanIds } = session;
|
|
190
|
+
const storedId = resetConversation(session);
|
|
191
|
+
session.interacted = true;
|
|
192
|
+
setActiveThreadId(null);
|
|
193
|
+
setMessages([]);
|
|
194
|
+
setIsStreaming(false);
|
|
195
|
+
return storedId ? [storedId, ...orphanIds] : orphanIds;
|
|
196
|
+
}, [session, stopForeign, cancelStream, setMessages, setIsStreaming]);
|
|
197
|
+
// Restore on mount, and again from scratch whenever the store changes. Compared by store, not
|
|
198
|
+
// by a first-run flag, so StrictMode re-running the mount effect is not taken for a switch.
|
|
199
|
+
const effectStoreRef = useRef(null);
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const previous = effectStoreRef.current;
|
|
202
|
+
effectStoreRef.current = { store };
|
|
203
|
+
const carryOverFrom = carryOverFromRef.current;
|
|
204
|
+
carryOverFromRef.current = undefined;
|
|
205
|
+
const migrateFrom = migrateFromRef.current;
|
|
206
|
+
migrateFromRef.current = undefined;
|
|
207
|
+
// Behind the guest store's pending writes, so none of them can recreate a moved record.
|
|
208
|
+
const migrate = (onScreen) => migrateFrom && store
|
|
209
|
+
? enqueueWrite(() => moveBackgroundTurns(migrateFrom, store)
|
|
210
|
+
.then(() => moveThreads(migrateFrom, store, onScreen))
|
|
211
|
+
.catch(() => { }))
|
|
212
|
+
: undefined;
|
|
213
|
+
if (carryOverFrom && store && migrateFrom) {
|
|
214
|
+
// A login mid-conversation: the guest conversation continues as the shopper's own.
|
|
215
|
+
stopForeign();
|
|
216
|
+
invalidateThreads();
|
|
217
|
+
const onScreen = {
|
|
218
|
+
threadIds: [session.storageThreadId, ...session.orphanIds].filter((id) => id !== null),
|
|
219
|
+
firstMessageId: messagesRef.current[0]?.id,
|
|
220
|
+
};
|
|
221
|
+
const guestWritesDone = enqueueWrite(async () => { });
|
|
222
|
+
resetWrites();
|
|
223
|
+
session.orphanIds = [];
|
|
224
|
+
session.lastSyncedAt = 0;
|
|
225
|
+
session.loadRequest += 1;
|
|
226
|
+
session.interacted = true;
|
|
227
|
+
// Saved into the shopper's store first: the guest copies are deleted only once it is there.
|
|
228
|
+
session.dirty = session.isStreaming;
|
|
229
|
+
const saved = persistNow();
|
|
230
|
+
Promise.all([guestWritesDone, saved, moveBackgroundTurns(migrateFrom, store)])
|
|
231
|
+
.then(() => moveThreads(migrateFrom, store, onScreen))
|
|
232
|
+
.catch(() => { })
|
|
233
|
+
.then(refreshThreads);
|
|
234
|
+
setIsHydrating(false);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
if (previous && previous.store !== store) {
|
|
238
|
+
// The store changed (e.g. a logout): start over instead of saving the old conversation into it.
|
|
239
|
+
clearForeignTimer();
|
|
240
|
+
cancelStream();
|
|
241
|
+
const settleInto = settleIntoRef.current;
|
|
242
|
+
settleIntoRef.current = undefined;
|
|
243
|
+
if (settleInto) {
|
|
244
|
+
const { snapshot, staleIds } = prepareSnapshot(session, normalizeHydratedMessages(settleInto.messages));
|
|
245
|
+
const { store: left } = settleInto;
|
|
246
|
+
// Written at once when the store can: a logout often navigates away before a queued write runs.
|
|
247
|
+
if (left.saveThreadSync) {
|
|
248
|
+
try {
|
|
249
|
+
left.saveThreadSync(snapshot, staleIds);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
/* ignore */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
enqueueWrite(() => saveThreadAndRetireStale(left, snapshot, staleIds).then(() => { }));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
migrate()?.then(refreshThreads);
|
|
260
|
+
resetConversation(session);
|
|
261
|
+
session.interacted = false;
|
|
262
|
+
invalidateThreads();
|
|
263
|
+
resetWrites();
|
|
264
|
+
}
|
|
265
|
+
if (!store) {
|
|
266
|
+
setIsHydrating(false);
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
setIsHydrating(true);
|
|
270
|
+
let cancelled = false;
|
|
271
|
+
(async () => {
|
|
272
|
+
try {
|
|
273
|
+
const summaries = await store.listThreads();
|
|
274
|
+
if (cancelled)
|
|
275
|
+
return;
|
|
276
|
+
if (session.interacted) {
|
|
277
|
+
refreshThreads();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
setThreads(summaries);
|
|
281
|
+
const targetId = initialThreadId ?? summaries[0]?.threadId;
|
|
282
|
+
const live = targetId ? resumeBackgroundTurn(store, targetId, screenOwner) : null;
|
|
283
|
+
if (live) {
|
|
284
|
+
showBackgroundTurn(live);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const chat = targetId ? await store.getThread(targetId) : null;
|
|
288
|
+
if (cancelled || session.interacted)
|
|
289
|
+
return;
|
|
290
|
+
if (!chat) {
|
|
291
|
+
if (targetId && isLocalThreadId(targetId))
|
|
292
|
+
session.serverThreadId = null;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
showStoredChat(chat);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* storage unavailable: start empty */
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
if (!cancelled)
|
|
302
|
+
setIsHydrating(false);
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
return () => {
|
|
306
|
+
cancelled = true;
|
|
307
|
+
clearForeignTimer();
|
|
308
|
+
};
|
|
309
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
310
|
+
}, [store]);
|
|
311
|
+
// Leaving the page or unmounting mid-answer keeps the turn, settled.
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!store || typeof window === 'undefined')
|
|
314
|
+
return undefined;
|
|
315
|
+
window.addEventListener('pagehide', flushBeforeUnload);
|
|
316
|
+
return () => window.removeEventListener('pagehide', flushBeforeUnload);
|
|
317
|
+
}, [store, flushBeforeUnload]);
|
|
318
|
+
useEffect(() => () => {
|
|
319
|
+
if (!detachTurn())
|
|
320
|
+
settleAndPersist();
|
|
321
|
+
}, [detachTurn, settleAndPersist]);
|
|
322
|
+
useEffect(() => subscribeBackgroundTurns(refreshThreads), [refreshThreads]);
|
|
323
|
+
// Follow changes another tab made to the list or to the thread on screen.
|
|
324
|
+
const syncFromStore = useCallback(async () => {
|
|
325
|
+
const { current } = storeRef;
|
|
326
|
+
if (!current)
|
|
327
|
+
return;
|
|
328
|
+
refreshThreads();
|
|
329
|
+
const activeId = session.storageThreadId;
|
|
330
|
+
if (!activeId)
|
|
331
|
+
return;
|
|
332
|
+
const stillActive = () => mountedRef.current && session.storageThreadId === activeId;
|
|
333
|
+
try {
|
|
334
|
+
const chat = await current.getThread(activeId);
|
|
335
|
+
if (!stillActive())
|
|
336
|
+
return;
|
|
337
|
+
if (!chat) {
|
|
338
|
+
const rekeyed = isLocalThreadId(activeId)
|
|
339
|
+
? await findRekeyedThread(current, messagesRef.current[0]?.id)
|
|
340
|
+
: null;
|
|
341
|
+
if (!stillActive())
|
|
342
|
+
return;
|
|
343
|
+
if (rekeyed) {
|
|
344
|
+
showStoredChat(rekeyed);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Evicted or expired, not deleted: keep it; writing back here would ping-pong with the other tab.
|
|
348
|
+
const deleted = current.isThreadDeleted ? await current.isThreadDeleted(activeId) : true;
|
|
349
|
+
if (!stillActive())
|
|
350
|
+
return;
|
|
351
|
+
if (deleted)
|
|
352
|
+
forgetConversation();
|
|
353
|
+
else
|
|
354
|
+
session.dirty = true;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Unsaved local work first; the save's completion looks at the store again.
|
|
358
|
+
if (session.isStreaming || session.dirty || pendingWritesRef.current > 0) {
|
|
359
|
+
missedSyncRef.current = true;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (chat.updatedAt > session.lastSyncedAt) {
|
|
363
|
+
showStoredChat(chat);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Turns another tab merged in while this one streamed: add them, and store the full view.
|
|
367
|
+
const shown = new Set(messagesRef.current.map((m) => m.id));
|
|
368
|
+
if (chat.messages.some((m) => !shown.has(m.id) && !isOwnMessage(session, m.id))) {
|
|
369
|
+
session.dirty = true;
|
|
370
|
+
setMessages((prev) => mergeMessages(chat.messages, prev));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
/* ignore */
|
|
375
|
+
}
|
|
376
|
+
}, [
|
|
377
|
+
session,
|
|
378
|
+
storeRef,
|
|
379
|
+
messagesRef,
|
|
380
|
+
refreshThreads,
|
|
381
|
+
showStoredChat,
|
|
382
|
+
forgetConversation,
|
|
383
|
+
mountedRef,
|
|
384
|
+
setMessages,
|
|
385
|
+
]);
|
|
386
|
+
// Save once a turn has settled.
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!storeRef.current || messages.length === 0)
|
|
389
|
+
return;
|
|
390
|
+
if (isStreaming || !session.dirty)
|
|
391
|
+
return;
|
|
392
|
+
session.dirty = false;
|
|
393
|
+
persistNow()?.then(() => {
|
|
394
|
+
if (!missedSyncRef.current) {
|
|
395
|
+
refreshThreads();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
missedSyncRef.current = false;
|
|
399
|
+
syncFromStore();
|
|
400
|
+
});
|
|
401
|
+
}, [messages, isStreaming, session, storeRef, persistNow, refreshThreads, syncFromStore]);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!store?.subscribe)
|
|
404
|
+
return undefined;
|
|
405
|
+
return store.subscribe(() => {
|
|
406
|
+
syncFromStore();
|
|
407
|
+
});
|
|
408
|
+
}, [store, syncFromStore]);
|
|
409
|
+
/** The user is sending a message: nothing still loading may land on top of it. */
|
|
410
|
+
const beginTurn = useCallback(() => {
|
|
411
|
+
session.interacted = true;
|
|
412
|
+
session.loadRequest += 1;
|
|
413
|
+
setIsHydrating(false);
|
|
414
|
+
}, [session]);
|
|
415
|
+
const clearHistory = useCallback(() => {
|
|
416
|
+
const storedIds = forgetConversation();
|
|
417
|
+
const { current } = storeRef;
|
|
418
|
+
if (storedIds.length === 0 || !current)
|
|
419
|
+
return;
|
|
420
|
+
// Queued behind pending saves so one of them cannot recreate the deleted thread.
|
|
421
|
+
enqueueWrite(() => Promise.all(storedIds.map((id) => current.deleteThread(id).catch(() => { }))).then(() => { })).then(refreshThreads);
|
|
422
|
+
}, [forgetConversation, storeRef, enqueueWrite, refreshThreads]);
|
|
423
|
+
const leaveConversation = useCallback(() => {
|
|
424
|
+
if (!detachTurn())
|
|
425
|
+
settleAndPersist()?.then(refreshThreads);
|
|
426
|
+
forgetConversation();
|
|
427
|
+
}, [detachTurn, settleAndPersist, refreshThreads, forgetConversation]);
|
|
428
|
+
const newThread = leaveConversation;
|
|
429
|
+
const switchThread = useCallback(async (threadId) => {
|
|
430
|
+
const { current } = storeRef;
|
|
431
|
+
if (!current || threadId === session.storageThreadId)
|
|
432
|
+
return;
|
|
433
|
+
leaveConversation();
|
|
434
|
+
const live = resumeBackgroundTurn(current, threadId, screenOwner);
|
|
435
|
+
if (live) {
|
|
436
|
+
showBackgroundTurn(live);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const request = session.loadRequest;
|
|
440
|
+
const stillWanted = () => mountedRef.current && request === session.loadRequest;
|
|
441
|
+
setIsHydrating(true);
|
|
442
|
+
try {
|
|
443
|
+
const chat = await current.getThread(threadId);
|
|
444
|
+
if (stillWanted() && chat)
|
|
445
|
+
showStoredChat(chat);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
/* keep the empty conversation */
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
if (stillWanted())
|
|
452
|
+
setIsHydrating(false);
|
|
453
|
+
}
|
|
454
|
+
}, [
|
|
455
|
+
session,
|
|
456
|
+
storeRef,
|
|
457
|
+
leaveConversation,
|
|
458
|
+
screenOwner,
|
|
459
|
+
showBackgroundTurn,
|
|
460
|
+
showStoredChat,
|
|
461
|
+
mountedRef,
|
|
462
|
+
]);
|
|
463
|
+
return {
|
|
464
|
+
isHydrating,
|
|
465
|
+
threads,
|
|
466
|
+
activeThreadId,
|
|
467
|
+
foreignInFlight,
|
|
468
|
+
beginTurn,
|
|
469
|
+
screenOwner,
|
|
470
|
+
clearHistory,
|
|
471
|
+
newThread,
|
|
472
|
+
switchThread,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
/** An answer streaming in another tab: blocks sending and settles after the grace period. */
|
|
3
|
+
export default function useForeignStream(session) {
|
|
4
|
+
const [foreignInFlight, setForeignInFlight] = useState(false);
|
|
5
|
+
const timerRef = useRef(null);
|
|
6
|
+
const setForeign = useCallback((value) => {
|
|
7
|
+
session.foreignInFlight = value;
|
|
8
|
+
setForeignInFlight(value);
|
|
9
|
+
}, [session]);
|
|
10
|
+
const clearTimer = useCallback(() => {
|
|
11
|
+
if (timerRef.current)
|
|
12
|
+
clearTimeout(timerRef.current);
|
|
13
|
+
timerRef.current = null;
|
|
14
|
+
}, []);
|
|
15
|
+
/** Show the thread as streaming elsewhere, then run `onExpire` unless cleared before. */
|
|
16
|
+
const watch = useCallback((remainingMs, onExpire) => {
|
|
17
|
+
clearTimer();
|
|
18
|
+
setForeign(true);
|
|
19
|
+
timerRef.current = setTimeout(() => {
|
|
20
|
+
timerRef.current = null;
|
|
21
|
+
setForeign(false);
|
|
22
|
+
onExpire();
|
|
23
|
+
}, remainingMs);
|
|
24
|
+
}, [clearTimer, setForeign]);
|
|
25
|
+
const stop = useCallback(() => {
|
|
26
|
+
clearTimer();
|
|
27
|
+
setForeign(false);
|
|
28
|
+
}, [clearTimer, setForeign]);
|
|
29
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
30
|
+
return { foreignInFlight, setForeignInFlight, watch, stop, clearTimer };
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import useIsMounted from '../useIsMounted';
|
|
3
|
+
/** Whether two thread lists describe the same state, so an unchanged re-read can be dropped. */
|
|
4
|
+
function sameThreads(a, b) {
|
|
5
|
+
return (a.length === b.length &&
|
|
6
|
+
a.every((t, i) => {
|
|
7
|
+
const other = b[i];
|
|
8
|
+
return (t.threadId === other.threadId &&
|
|
9
|
+
t.updatedAt === other.updatedAt &&
|
|
10
|
+
t.createdAt === other.createdAt &&
|
|
11
|
+
t.inFlight === other.inFlight &&
|
|
12
|
+
t.title === other.title);
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
/** The stored thread list, re-read on demand; only the latest read may land. */
|
|
16
|
+
export default function useThreadList(storeRef) {
|
|
17
|
+
const [threads, setThreads] = useState([]);
|
|
18
|
+
const requestRef = useRef(0);
|
|
19
|
+
const mountedRef = useIsMounted();
|
|
20
|
+
/** Drop the result of any read still in flight. */
|
|
21
|
+
const invalidate = useCallback(() => {
|
|
22
|
+
requestRef.current += 1;
|
|
23
|
+
}, []);
|
|
24
|
+
const refreshThreads = useCallback(async () => {
|
|
25
|
+
const store = storeRef.current;
|
|
26
|
+
if (!store)
|
|
27
|
+
return;
|
|
28
|
+
requestRef.current += 1;
|
|
29
|
+
const request = requestRef.current;
|
|
30
|
+
try {
|
|
31
|
+
const list = await store.listThreads();
|
|
32
|
+
if (!mountedRef.current || request !== requestRef.current)
|
|
33
|
+
return;
|
|
34
|
+
setThreads((prev) => (sameThreads(prev, list) ? prev : list));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
}, [storeRef, mountedRef]);
|
|
40
|
+
return { threads, setThreads, refreshThreads, invalidate };
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
/** Runs storage writes one after another, in the order they were issued. */
|
|
3
|
+
export default function useWriteQueue() {
|
|
4
|
+
const chainRef = useRef(Promise.resolve());
|
|
5
|
+
const enqueue = useCallback((run) => {
|
|
6
|
+
chainRef.current = chainRef.current.then(run, run);
|
|
7
|
+
return chainRef.current;
|
|
8
|
+
}, []);
|
|
9
|
+
/** Forget pending writes, so a slow one cannot delay writes into a different store. */
|
|
10
|
+
const reset = useCallback(() => {
|
|
11
|
+
chainRef.current = Promise.resolve();
|
|
12
|
+
}, []);
|
|
13
|
+
return { enqueue, reset };
|
|
14
|
+
}
|