@turingfocus/chat-runtime 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache-record.d.ts +20 -0
- package/dist/cache-record.d.ts.map +1 -0
- package/dist/cache-record.js +139 -0
- package/dist/cache-record.js.map +1 -0
- package/dist/chat-client.d.ts +8 -0
- package/dist/chat-client.d.ts.map +1 -1
- package/dist/chat-client.js +367 -7
- package/dist/chat-client.js.map +1 -1
- package/dist/conversation-cache.d.ts +77 -0
- package/dist/conversation-cache.d.ts.map +1 -0
- package/dist/conversation-cache.js +501 -0
- package/dist/conversation-cache.js.map +1 -0
- package/dist/conversation-workspace.d.ts.map +1 -1
- package/dist/conversation-workspace.js +5 -1
- package/dist/conversation-workspace.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/chat-client.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { answerInteractionInputSchema, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, sendMessageInputSchema, } from "@turingfocus/chat-protocol";
|
|
1
|
+
import { answerInteractionInputSchema, getTimelineItemKey, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, sendMessageInputSchema, } from "@turingfocus/chat-protocol";
|
|
2
2
|
import { createGatewayNotificationQueue, } from "./gateway-notification-queue.js";
|
|
3
3
|
import { cloneImmutable, deepEqual } from "./immutable.js";
|
|
4
4
|
import { applySnapshotUpdate, applyRuntimeSnapshotError, createSnapshotState, rebaseSnapshotState, updateConversationId, updateSnapshotState, } from "./snapshot-state.js";
|
|
5
5
|
import { SnapshotStore, } from "./snapshot-store.js";
|
|
6
|
-
import { mergeHistoryTimeline } from "./timeline.js";
|
|
6
|
+
import { createTimelineState, mergeHistoryTimeline } from "./timeline.js";
|
|
7
|
+
import { ConversationCache, mergeCachedHistory, } from "./conversation-cache.js";
|
|
8
|
+
import { cachedDisplaySnapshot } from "./cache-record.js";
|
|
7
9
|
import { areComposerLongTextsValid, emptyComposerDraft, rebaseComposerLongTexts, resolveComposerDraftText, } from "./composer-draft.js";
|
|
8
10
|
const runtimeFailure = (code, message, conversationId) => ({
|
|
9
11
|
ok: false,
|
|
@@ -42,6 +44,19 @@ const runCleanup = async (cleanup, onError) => {
|
|
|
42
44
|
*/
|
|
43
45
|
export class ChatClient {
|
|
44
46
|
#gateway;
|
|
47
|
+
#cache;
|
|
48
|
+
#cacheListeners = new Set();
|
|
49
|
+
#cacheRequestId = 0;
|
|
50
|
+
#cacheReadOnly = false;
|
|
51
|
+
#cacheWriteScheduled = false;
|
|
52
|
+
#clearedSnapshot = null;
|
|
53
|
+
#cacheState = Object.freeze({
|
|
54
|
+
status: "idle",
|
|
55
|
+
source: "none",
|
|
56
|
+
freshness: "miss",
|
|
57
|
+
unavailableAttachments: 0,
|
|
58
|
+
storageError: false,
|
|
59
|
+
});
|
|
45
60
|
#onUnhandledError;
|
|
46
61
|
#snapshotStore;
|
|
47
62
|
#disposed = false;
|
|
@@ -66,12 +81,141 @@ export class ChatClient {
|
|
|
66
81
|
#pendingHandoff;
|
|
67
82
|
constructor(options) {
|
|
68
83
|
this.#gateway = options.gateway;
|
|
84
|
+
this.#cache =
|
|
85
|
+
options.cache === false
|
|
86
|
+
? undefined
|
|
87
|
+
: new ConversationCache({
|
|
88
|
+
...options.cache,
|
|
89
|
+
onEvent: (event) => {
|
|
90
|
+
if (event.kind === "storage-error" ||
|
|
91
|
+
event.kind === "invalid-record" ||
|
|
92
|
+
event.kind === "write-conflict")
|
|
93
|
+
this.#setCacheState({
|
|
94
|
+
...this.#cacheState,
|
|
95
|
+
storageError: true,
|
|
96
|
+
});
|
|
97
|
+
if (options.cache)
|
|
98
|
+
options.cache.onEvent?.(event);
|
|
99
|
+
},
|
|
100
|
+
}, (id) => {
|
|
101
|
+
if (this.getSnapshot()?.conversation.id !== id)
|
|
102
|
+
this.#discardComposerDraft(id);
|
|
103
|
+
});
|
|
104
|
+
if (this.#cache === undefined)
|
|
105
|
+
this.#cacheState = Object.freeze({
|
|
106
|
+
...this.#cacheState,
|
|
107
|
+
status: "disabled",
|
|
108
|
+
});
|
|
69
109
|
this.#onUnhandledError = options.onUnhandledError;
|
|
70
110
|
this.#snapshotStore = new SnapshotStore({
|
|
71
111
|
onListenerError: (cause) => {
|
|
72
112
|
this.#reportUnhandledError({ cause, source: "listener" });
|
|
73
113
|
},
|
|
74
114
|
});
|
|
115
|
+
if (options.cache !== false && options.cache?.storage !== undefined) {
|
|
116
|
+
this.#snapshotStore.subscribe(() => this.#scheduleCacheWrite());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
getCacheState() {
|
|
120
|
+
return this.#cacheState;
|
|
121
|
+
}
|
|
122
|
+
subscribeCacheState(listener) {
|
|
123
|
+
const isDisposed = () => this.#disposed;
|
|
124
|
+
let closed = this.#disposed;
|
|
125
|
+
if (!closed)
|
|
126
|
+
this.#cacheListeners.add(listener);
|
|
127
|
+
return {
|
|
128
|
+
get closed() {
|
|
129
|
+
return closed || isDisposed();
|
|
130
|
+
},
|
|
131
|
+
dispose: () => {
|
|
132
|
+
closed = true;
|
|
133
|
+
this.#cacheListeners.delete(listener);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async clearConversationCache(conversationId, options) {
|
|
138
|
+
if (this.#disposed)
|
|
139
|
+
return;
|
|
140
|
+
if (this.#cacheState.conversationId === conversationId)
|
|
141
|
+
this.cancelPendingConversationLoad();
|
|
142
|
+
if (this.getSnapshot()?.conversation.id === conversationId)
|
|
143
|
+
this.#clearedSnapshot = this.getSnapshot();
|
|
144
|
+
this.#discardComposerDraft(conversationId);
|
|
145
|
+
const clearing = this.#cache?.clear(conversationId, options);
|
|
146
|
+
if (this.getSnapshot()?.conversation.id === conversationId)
|
|
147
|
+
this.#clearCachedView();
|
|
148
|
+
if (this.#cacheState.conversationId === conversationId)
|
|
149
|
+
this.#setCacheState({
|
|
150
|
+
...this.#cacheState,
|
|
151
|
+
source: "none",
|
|
152
|
+
freshness: "miss",
|
|
153
|
+
});
|
|
154
|
+
await clearing;
|
|
155
|
+
}
|
|
156
|
+
async clearCache(options) {
|
|
157
|
+
if (this.#disposed)
|
|
158
|
+
return;
|
|
159
|
+
this.cancelPendingConversationLoad();
|
|
160
|
+
this.#clearedSnapshot = this.getSnapshot();
|
|
161
|
+
for (const id of [...this.#composerDrafts.keys()])
|
|
162
|
+
this.#discardComposerDraft(id);
|
|
163
|
+
const clearing = this.#cache?.clear(undefined, options);
|
|
164
|
+
this.#clearCachedView();
|
|
165
|
+
this.#setCacheState({
|
|
166
|
+
status: this.#cache === undefined ? "disabled" : "idle",
|
|
167
|
+
source: "none",
|
|
168
|
+
freshness: "miss",
|
|
169
|
+
unavailableAttachments: 0,
|
|
170
|
+
storageError: this.#cache?.storageError ?? false,
|
|
171
|
+
});
|
|
172
|
+
await clearing;
|
|
173
|
+
}
|
|
174
|
+
#clearCachedView() {
|
|
175
|
+
const snapshot = this.getSnapshot();
|
|
176
|
+
if (!this.#cacheReadOnly || snapshot === null)
|
|
177
|
+
return;
|
|
178
|
+
// Clearing local content does not delete the server conversation or selection.
|
|
179
|
+
this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot({
|
|
180
|
+
...snapshot,
|
|
181
|
+
timeline: [],
|
|
182
|
+
pageInfo: { hasPreviousPage: false },
|
|
183
|
+
})));
|
|
184
|
+
}
|
|
185
|
+
async flushCache(options) {
|
|
186
|
+
this.#captureCache(options);
|
|
187
|
+
await this.#cache?.flush();
|
|
188
|
+
}
|
|
189
|
+
#captureCache(options) {
|
|
190
|
+
const snapshot = this.getSnapshot();
|
|
191
|
+
if (this.#disposed ||
|
|
192
|
+
this.#cacheReadOnly ||
|
|
193
|
+
snapshot === null ||
|
|
194
|
+
snapshot === this.#clearedSnapshot)
|
|
195
|
+
return;
|
|
196
|
+
this.#cache?.put(snapshot, this.getComposerDraft(snapshot.conversation.id), options);
|
|
197
|
+
}
|
|
198
|
+
#scheduleCacheWrite() {
|
|
199
|
+
if (this.#cacheWriteScheduled || this.#disposed || this.#cacheReadOnly)
|
|
200
|
+
return;
|
|
201
|
+
this.#cacheWriteScheduled = true;
|
|
202
|
+
void Promise.resolve().then(() => {
|
|
203
|
+
this.#cacheWriteScheduled = false;
|
|
204
|
+
this.#captureCache({ deadlineAt: Date.now() + 5000 });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
#setCacheState(state) {
|
|
208
|
+
if (this.#disposed || this.#cache === undefined)
|
|
209
|
+
return;
|
|
210
|
+
this.#cacheState = Object.freeze(state);
|
|
211
|
+
for (const listener of this.#cacheListeners) {
|
|
212
|
+
try {
|
|
213
|
+
listener();
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
this.#reportUnhandledError({ cause, source: "listener" });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
75
219
|
}
|
|
76
220
|
get disposed() {
|
|
77
221
|
return this.#disposed;
|
|
@@ -95,6 +239,18 @@ export class ChatClient {
|
|
|
95
239
|
this.#composerDrafts.set(conversationId, empty);
|
|
96
240
|
return empty;
|
|
97
241
|
}
|
|
242
|
+
#discardComposerDraft(conversationId) {
|
|
243
|
+
this.#composerDrafts.delete(conversationId);
|
|
244
|
+
const empty = emptyComposerDraft(conversationId);
|
|
245
|
+
for (const listener of this.#composerListeners) {
|
|
246
|
+
try {
|
|
247
|
+
listener(empty);
|
|
248
|
+
}
|
|
249
|
+
catch (cause) {
|
|
250
|
+
this.#reportUnhandledError({ cause, source: "listener" });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
98
254
|
setComposerDraft(input) {
|
|
99
255
|
if (this.#disposed)
|
|
100
256
|
return this.getComposerDraft(input.conversationId);
|
|
@@ -113,6 +269,10 @@ export class ChatClient {
|
|
|
113
269
|
revision: current.revision + 1,
|
|
114
270
|
});
|
|
115
271
|
this.#composerDrafts.set(input.conversationId, next);
|
|
272
|
+
this.#cache?.updateDraft(input.conversationId, next, {
|
|
273
|
+
deadlineAt: Date.now() + 5000,
|
|
274
|
+
}, input.attachments === undefined);
|
|
275
|
+
this.#scheduleCacheWrite();
|
|
116
276
|
for (const listener of this.#composerListeners) {
|
|
117
277
|
try {
|
|
118
278
|
listener(next);
|
|
@@ -187,6 +347,7 @@ export class ChatClient {
|
|
|
187
347
|
* that load completes successfully while transport cleanup settles.
|
|
188
348
|
*/
|
|
189
349
|
cancelPendingConversationLoad() {
|
|
350
|
+
this.#cacheRequestId += 1;
|
|
190
351
|
if (this.#disposed || this.#pendingLoadRequestId === undefined)
|
|
191
352
|
return;
|
|
192
353
|
this.#loadRequestId += 1;
|
|
@@ -292,8 +453,15 @@ export class ChatClient {
|
|
|
292
453
|
result.value.deletedConversationId !== parsed.data.conversationId) {
|
|
293
454
|
return runtimeFailure("validation", "Gateway deleted a different conversation", parsed.data.conversationId);
|
|
294
455
|
}
|
|
456
|
+
const cacheClear = result.ok
|
|
457
|
+
? this.#cache
|
|
458
|
+
?.clear(parsed.data.conversationId, parsed.data)
|
|
459
|
+
.catch(() => undefined)
|
|
460
|
+
: undefined;
|
|
295
461
|
if (result.ok) {
|
|
296
|
-
this.#
|
|
462
|
+
if (this.#cacheState.conversationId === parsed.data.conversationId)
|
|
463
|
+
this.#cacheRequestId += 1;
|
|
464
|
+
this.#discardComposerDraft(parsed.data.conversationId);
|
|
297
465
|
const sendKeyPrefix = `${parsed.data.conversationId.length}:${parsed.data.conversationId}:`;
|
|
298
466
|
for (const key of this.#composerSendsInFlight.keys()) {
|
|
299
467
|
if (key.startsWith(sendKeyPrefix))
|
|
@@ -332,9 +500,144 @@ export class ChatClient {
|
|
|
332
500
|
this.#snapshotStore.clear();
|
|
333
501
|
await this.#runSubscriptionCleanup(() => subscription?.dispose(parsed.data), parsed.data.conversationId);
|
|
334
502
|
}
|
|
503
|
+
await cacheClear;
|
|
335
504
|
return result;
|
|
336
505
|
}
|
|
337
506
|
async loadConversation(input) {
|
|
507
|
+
if (this.#cache === undefined ||
|
|
508
|
+
input.previousCursor !== undefined ||
|
|
509
|
+
this.#disposed)
|
|
510
|
+
return this.#loadConversationFromGateway(input);
|
|
511
|
+
if (isGatewayDeadlineExceeded(input))
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
error: createGatewayDeadlineExceededError(input.conversationId),
|
|
515
|
+
};
|
|
516
|
+
this.cancelPendingConversationLoad();
|
|
517
|
+
const ticket = this.#cacheRequestId;
|
|
518
|
+
const epoch = this.#cache.epochFor(input.conversationId);
|
|
519
|
+
this.#captureCache(input);
|
|
520
|
+
let entry = this.#cache.peek(input.conversationId);
|
|
521
|
+
let source = entry === undefined ? "none" : "memory";
|
|
522
|
+
if (entry === undefined && this.#cache.hasStorage) {
|
|
523
|
+
entry = await this.#cache.restore(input.conversationId, input);
|
|
524
|
+
if (entry !== undefined)
|
|
525
|
+
source = "storage";
|
|
526
|
+
}
|
|
527
|
+
if (this.#disposed ||
|
|
528
|
+
ticket !== this.#cacheRequestId ||
|
|
529
|
+
epoch !== this.#cache.epochFor(input.conversationId))
|
|
530
|
+
return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
|
|
531
|
+
this.#setCacheState({
|
|
532
|
+
conversationId: input.conversationId,
|
|
533
|
+
status: "syncing",
|
|
534
|
+
source,
|
|
535
|
+
freshness: entry === undefined
|
|
536
|
+
? this.#cache.wasExpired(input.conversationId)
|
|
537
|
+
? "expired"
|
|
538
|
+
: "miss"
|
|
539
|
+
: this.#cache.freshness(entry.savedAt),
|
|
540
|
+
...(entry === undefined ? {} : { savedAt: entry.savedAt }),
|
|
541
|
+
unavailableAttachments: (entry?.unavailableAttachments ?? 0) +
|
|
542
|
+
(entry?.attachmentKeys?.length ?? 0),
|
|
543
|
+
storageError: this.#cache.storageError,
|
|
544
|
+
});
|
|
545
|
+
if (this.#disposed ||
|
|
546
|
+
ticket !== this.#cacheRequestId ||
|
|
547
|
+
epoch !== this.#cache.epochFor(input.conversationId))
|
|
548
|
+
return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
|
|
549
|
+
if (entry !== undefined &&
|
|
550
|
+
(this.getSnapshot()?.conversation.id !== input.conversationId ||
|
|
551
|
+
this.#cacheReadOnly)) {
|
|
552
|
+
const oldSubscription = this.#gatewaySubscription;
|
|
553
|
+
this.#gatewaySubscription = undefined;
|
|
554
|
+
this.#generation += 1;
|
|
555
|
+
this.#conversationEpoch += 1;
|
|
556
|
+
this.#historyRequestId += 1;
|
|
557
|
+
this.#cacheReadOnly = true;
|
|
558
|
+
this.#snapshotStore.commit(createSnapshotState(cachedDisplaySnapshot(entry.snapshot)));
|
|
559
|
+
void this.#runSubscriptionCleanup(() => oldSubscription?.dispose(input), input.conversationId);
|
|
560
|
+
if (ticket === this.#cacheRequestId &&
|
|
561
|
+
epoch === this.#cache.epochFor(input.conversationId))
|
|
562
|
+
this.#restoreCachedDraft(entry, source, input, ticket);
|
|
563
|
+
}
|
|
564
|
+
if (this.#disposed ||
|
|
565
|
+
ticket !== this.#cacheRequestId ||
|
|
566
|
+
epoch !== this.#cache.epochFor(input.conversationId))
|
|
567
|
+
return runtimeFailure("conflict", "Cached load was superseded", input.conversationId);
|
|
568
|
+
const result = await this.#loadConversationFromGateway(input);
|
|
569
|
+
if (this.#disposed ||
|
|
570
|
+
ticket !== this.#cacheRequestId ||
|
|
571
|
+
epoch !== this.#cache.epochFor(input.conversationId))
|
|
572
|
+
return result;
|
|
573
|
+
if (result.ok) {
|
|
574
|
+
this.#cacheReadOnly = false;
|
|
575
|
+
this.#captureCache(input);
|
|
576
|
+
this.#setCacheState({
|
|
577
|
+
...this.#cacheState,
|
|
578
|
+
status: "ready",
|
|
579
|
+
freshness: "fresh",
|
|
580
|
+
savedAt: this.#cache.now(),
|
|
581
|
+
storageError: this.#cache.storageError,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
this.#setCacheState({
|
|
586
|
+
...this.#cacheState,
|
|
587
|
+
status: "error",
|
|
588
|
+
storageError: this.#cache.storageError,
|
|
589
|
+
});
|
|
590
|
+
if (result.error.code === "not-found" ||
|
|
591
|
+
result.error.code === "authorization" ||
|
|
592
|
+
result.error.code === "authentication") {
|
|
593
|
+
await this.clearConversationCache(input.conversationId, input).catch(() => undefined);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
#restoreCachedDraft(entry, source, input, ticket) {
|
|
599
|
+
const existing = this.#composerDrafts.get(input.conversationId);
|
|
600
|
+
if (existing !== undefined && existing.revision > 0)
|
|
601
|
+
return;
|
|
602
|
+
const draft = this.setComposerDraft({
|
|
603
|
+
conversationId: input.conversationId,
|
|
604
|
+
text: entry.draft.text,
|
|
605
|
+
longTexts: entry.draft.longTexts,
|
|
606
|
+
...(source === "memory" ? { attachments: entry.draft.attachments } : {}),
|
|
607
|
+
});
|
|
608
|
+
if (source !== "storage")
|
|
609
|
+
return;
|
|
610
|
+
const epoch = this.#cache.epochFor(input.conversationId);
|
|
611
|
+
this.#setCacheState({
|
|
612
|
+
...this.#cacheState,
|
|
613
|
+
unavailableAttachments: (entry.attachmentKeys?.length ?? 0) +
|
|
614
|
+
(entry.unavailableAttachments ?? 0),
|
|
615
|
+
});
|
|
616
|
+
void this.#cache.restoreAttachments(entry, input).then((restored) => {
|
|
617
|
+
if (this.#disposed ||
|
|
618
|
+
epoch !== this.#cache.epochFor(input.conversationId) ||
|
|
619
|
+
this.getComposerDraft(input.conversationId).revision !== draft.revision)
|
|
620
|
+
return;
|
|
621
|
+
const validated = sendMessageInputSchema.safeParse({
|
|
622
|
+
...input,
|
|
623
|
+
text: draft.text || " ",
|
|
624
|
+
attachments: restored.attachments,
|
|
625
|
+
});
|
|
626
|
+
this.setComposerDraft({
|
|
627
|
+
conversationId: input.conversationId,
|
|
628
|
+
attachments: validated.success ? restored.attachments : [],
|
|
629
|
+
});
|
|
630
|
+
const unavailable = restored.unavailable +
|
|
631
|
+
(validated.success ? 0 : restored.attachments.length);
|
|
632
|
+
this.#cache.setUnavailableAttachments(input.conversationId, unavailable, input);
|
|
633
|
+
if (ticket === this.#cacheRequestId)
|
|
634
|
+
this.#setCacheState({
|
|
635
|
+
...this.#cacheState,
|
|
636
|
+
unavailableAttachments: unavailable,
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async #loadConversationFromGateway(input) {
|
|
338
641
|
if (this.#disposed) {
|
|
339
642
|
return runtimeFailure("conflict", "ChatClient has already been disposed", input.conversationId);
|
|
340
643
|
}
|
|
@@ -373,7 +676,10 @@ export class ChatClient {
|
|
|
373
676
|
await this.#discardGatewaySubscription(subscribed, input);
|
|
374
677
|
return runtimeFailure("conflict", "Conversation load was superseded", input.conversationId);
|
|
375
678
|
}
|
|
376
|
-
|
|
679
|
+
let loaded = await this.#gateway.loadConversation(input);
|
|
680
|
+
if (loaded.ok && loaded.value.conversation.id === input.conversationId) {
|
|
681
|
+
loaded = await this.#bridgeCachedHistory(input, loaded.value, requestId);
|
|
682
|
+
}
|
|
377
683
|
if (!loaded.ok) {
|
|
378
684
|
this.#clearPendingLoad(requestId);
|
|
379
685
|
await this.#discardGatewaySubscription(subscribed, input);
|
|
@@ -422,9 +728,11 @@ export class ChatClient {
|
|
|
422
728
|
this.#pendingLoadConversationId === input.conversationId
|
|
423
729
|
? this.#pendingLoadConversationOverride
|
|
424
730
|
: undefined;
|
|
731
|
+
const cached = this.#cache?.peek(input.conversationId);
|
|
732
|
+
const loadedWithHistory = mergeCachedHistory(loaded.value, cached?.snapshot);
|
|
425
733
|
const loadedState = createSnapshotState(conversationOverride === undefined
|
|
426
|
-
?
|
|
427
|
-
: { ...
|
|
734
|
+
? loadedWithHistory
|
|
735
|
+
: { ...loadedWithHistory, conversation: conversationOverride });
|
|
428
736
|
const currentState = this.#snapshotStore.state;
|
|
429
737
|
const handoff = this.#pendingHandoff;
|
|
430
738
|
let nextState = handoff?.requestId === requestId && currentState !== null
|
|
@@ -439,6 +747,7 @@ export class ChatClient {
|
|
|
439
747
|
nextState = this.#guardInteractionTransition(currentState, nextState);
|
|
440
748
|
}
|
|
441
749
|
this.#clearPendingLoad(requestId);
|
|
750
|
+
this.#cacheReadOnly = false;
|
|
442
751
|
this.#snapshotStore.commit(nextState);
|
|
443
752
|
if (!this.#disposed)
|
|
444
753
|
notificationQueue.activate(generation);
|
|
@@ -609,6 +918,10 @@ export class ChatClient {
|
|
|
609
918
|
dispose(options) {
|
|
610
919
|
if (this.#disposePromise !== undefined)
|
|
611
920
|
return this.#disposePromise;
|
|
921
|
+
this.#captureCache(options);
|
|
922
|
+
const cacheClosed = this.#cache?.close();
|
|
923
|
+
this.#cacheListeners.clear();
|
|
924
|
+
this.#cacheRequestId += 1;
|
|
612
925
|
this.#disposed = true;
|
|
613
926
|
this.#conversationEpoch += 1;
|
|
614
927
|
this.#generation += 1;
|
|
@@ -629,6 +942,7 @@ export class ChatClient {
|
|
|
629
942
|
const subscription = this.#gatewaySubscription;
|
|
630
943
|
this.#gatewaySubscription = undefined;
|
|
631
944
|
this.#disposePromise = Promise.allSettled([
|
|
945
|
+
cacheClosed,
|
|
632
946
|
Promise.resolve().then(() => subscription?.dispose(options)),
|
|
633
947
|
Promise.resolve().then(() => this.#gateway.dispose(options)),
|
|
634
948
|
]).then((results) => {
|
|
@@ -641,6 +955,42 @@ export class ChatClient {
|
|
|
641
955
|
});
|
|
642
956
|
return this.#disposePromise;
|
|
643
957
|
}
|
|
958
|
+
async #bridgeCachedHistory(input, snapshot, requestId) {
|
|
959
|
+
const cached = this.#cache?.peek(input.conversationId);
|
|
960
|
+
if (cached === undefined || cached.snapshot.timeline.length === 0)
|
|
961
|
+
return { ok: true, value: snapshot };
|
|
962
|
+
const keys = new Set(cached.snapshot.timeline.map(getTimelineItemKey));
|
|
963
|
+
const cursors = new Set();
|
|
964
|
+
let current = snapshot;
|
|
965
|
+
while (current.pageInfo.hasPreviousPage &&
|
|
966
|
+
!current.timeline.some((item) => keys.has(getTimelineItemKey(item)))) {
|
|
967
|
+
const cursor = current.pageInfo.previousCursor;
|
|
968
|
+
if (this.#disposed || requestId !== this.#loadRequestId)
|
|
969
|
+
return runtimeFailure("conflict", "Cache synchronization was superseded", input.conversationId);
|
|
970
|
+
if (isGatewayDeadlineExceeded(input))
|
|
971
|
+
return {
|
|
972
|
+
ok: false,
|
|
973
|
+
error: createGatewayDeadlineExceededError(input.conversationId),
|
|
974
|
+
};
|
|
975
|
+
if (cursor === undefined || cursors.has(cursor) || cursors.size >= 50)
|
|
976
|
+
return runtimeFailure("conflict", "Cache synchronization could not establish continuous history", input.conversationId);
|
|
977
|
+
cursors.add(cursor);
|
|
978
|
+
const page = await this.#gateway.loadConversation({
|
|
979
|
+
...input,
|
|
980
|
+
previousCursor: cursor,
|
|
981
|
+
});
|
|
982
|
+
if (!page.ok)
|
|
983
|
+
return page;
|
|
984
|
+
if (page.value.conversation.id !== input.conversationId)
|
|
985
|
+
return runtimeFailure("validation", "Gateway returned history for a different conversation", input.conversationId);
|
|
986
|
+
current = {
|
|
987
|
+
...current,
|
|
988
|
+
timeline: mergeHistoryTimeline(createTimelineState(current.timeline), page.value.timeline).items,
|
|
989
|
+
pageInfo: page.value.pageInfo,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
return { ok: true, value: current };
|
|
993
|
+
}
|
|
644
994
|
async #loadHistory(input) {
|
|
645
995
|
const active = this.#activeState(input.conversationId);
|
|
646
996
|
if (!active.ok)
|
|
@@ -702,6 +1052,8 @@ export class ChatClient {
|
|
|
702
1052
|
if (this.#snapshotStore.state?.snapshot.conversation.id !== conversationId) {
|
|
703
1053
|
return runtimeFailure("conflict", "Conversation is not active in this ChatClient", conversationId);
|
|
704
1054
|
}
|
|
1055
|
+
if (this.#cacheReadOnly)
|
|
1056
|
+
return runtimeFailure("conflict", "Cached conversation is read-only until synchronization succeeds", conversationId);
|
|
705
1057
|
const lifecycle = this.#snapshotStore.state.snapshot.lifecycle;
|
|
706
1058
|
if (lifecycle !== undefined && !isChatLifecycleOperable(lifecycle)) {
|
|
707
1059
|
return runtimeFailure(lifecycle.status === "auth-required"
|
|
@@ -792,7 +1144,15 @@ export class ChatClient {
|
|
|
792
1144
|
return;
|
|
793
1145
|
}
|
|
794
1146
|
const state = this.#snapshotStore.latestState();
|
|
795
|
-
this.#
|
|
1147
|
+
const prepared = this.#cache !== undefined &&
|
|
1148
|
+
update.kind === "snapshot.replace" &&
|
|
1149
|
+
update.snapshot.conversation.id === state.snapshot.conversation.id
|
|
1150
|
+
? {
|
|
1151
|
+
...update,
|
|
1152
|
+
snapshot: mergeCachedHistory(update.snapshot, state.snapshot),
|
|
1153
|
+
}
|
|
1154
|
+
: update;
|
|
1155
|
+
this.#snapshotStore.commit(this.#guardInteractionTransition(state, applySnapshotUpdate(state, prepared)));
|
|
796
1156
|
}
|
|
797
1157
|
#guardInteractionTransition(current, candidate) {
|
|
798
1158
|
const currentInteraction = current.snapshot.pendingInteraction;
|