@turingfocus/chat-runtime 0.7.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 +18 -1
- package/dist/chat-client.d.ts.map +1 -1
- package/dist/chat-client.js +534 -6
- package/dist/chat-client.js.map +1 -1
- package/dist/composer-draft.d.ts +39 -0
- package/dist/composer-draft.d.ts.map +1 -0
- package/dist/composer-draft.js +96 -0
- package/dist/composer-draft.js.map +1 -0
- 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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/references.d.ts +17 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +29 -0
- package/dist/references.js.map +1 -0
- package/package.json +2 -2
package/dist/chat-client.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { answerInteractionInputSchema, createConversationInputSchema, createGatewayDeadlineExceededError, deleteConversationInputSchema, getAskUserInteractionAnswerValidationError, isGatewayDeadlineExceeded, isChatLifecycleOperable, isGatewayOperationSupported, listConversationsInputSchema, renameConversationInputSchema, } 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";
|
|
9
|
+
import { areComposerLongTextsValid, emptyComposerDraft, rebaseComposerLongTexts, resolveComposerDraftText, } from "./composer-draft.js";
|
|
7
10
|
const runtimeFailure = (code, message, conversationId) => ({
|
|
8
11
|
ok: false,
|
|
9
12
|
error: cloneImmutable({
|
|
@@ -41,6 +44,19 @@ const runCleanup = async (cleanup, onError) => {
|
|
|
41
44
|
*/
|
|
42
45
|
export class ChatClient {
|
|
43
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
|
+
});
|
|
44
60
|
#onUnhandledError;
|
|
45
61
|
#snapshotStore;
|
|
46
62
|
#disposed = false;
|
|
@@ -50,6 +66,10 @@ export class ChatClient {
|
|
|
50
66
|
#generation = 0;
|
|
51
67
|
#gatewaySubscription;
|
|
52
68
|
#interactionAnswersInFlight = new Set();
|
|
69
|
+
#composerDrafts = new Map();
|
|
70
|
+
#composerListeners = new Set();
|
|
71
|
+
#composerSendsInFlight = new Map();
|
|
72
|
+
#messageSendsInFlight = new Map();
|
|
53
73
|
#retiredInteractionIdentities = new Set();
|
|
54
74
|
#supersededInteractionAnswers = new Set();
|
|
55
75
|
#historyRequestId = 0;
|
|
@@ -61,12 +81,141 @@ export class ChatClient {
|
|
|
61
81
|
#pendingHandoff;
|
|
62
82
|
constructor(options) {
|
|
63
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
|
+
});
|
|
64
109
|
this.#onUnhandledError = options.onUnhandledError;
|
|
65
110
|
this.#snapshotStore = new SnapshotStore({
|
|
66
111
|
onListenerError: (cause) => {
|
|
67
112
|
this.#reportUnhandledError({ cause, source: "listener" });
|
|
68
113
|
},
|
|
69
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
|
+
}
|
|
70
219
|
}
|
|
71
220
|
get disposed() {
|
|
72
221
|
return this.#disposed;
|
|
@@ -81,12 +230,124 @@ export class ChatClient {
|
|
|
81
230
|
subscribeState(listener) {
|
|
82
231
|
return this.#snapshotStore.subscribeState(listener);
|
|
83
232
|
}
|
|
233
|
+
getComposerDraft(conversationId) {
|
|
234
|
+
const existing = this.#composerDrafts.get(conversationId);
|
|
235
|
+
if (existing !== undefined)
|
|
236
|
+
return existing;
|
|
237
|
+
const empty = emptyComposerDraft(conversationId);
|
|
238
|
+
if (!this.#disposed)
|
|
239
|
+
this.#composerDrafts.set(conversationId, empty);
|
|
240
|
+
return empty;
|
|
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
|
+
}
|
|
254
|
+
setComposerDraft(input) {
|
|
255
|
+
if (this.#disposed)
|
|
256
|
+
return this.getComposerDraft(input.conversationId);
|
|
257
|
+
const current = this.getComposerDraft(input.conversationId);
|
|
258
|
+
const nextText = input.text ?? current.text;
|
|
259
|
+
const nextLongTexts = input.longTexts ??
|
|
260
|
+
rebaseComposerLongTexts(current.text, nextText, current.longTexts);
|
|
261
|
+
if (!areComposerLongTextsValid(nextText, nextLongTexts)) {
|
|
262
|
+
throw new TypeError("Composer long-text anchors are invalid");
|
|
263
|
+
}
|
|
264
|
+
const next = cloneImmutable({
|
|
265
|
+
conversationId: input.conversationId,
|
|
266
|
+
text: nextText,
|
|
267
|
+
attachments: input.attachments ?? current.attachments,
|
|
268
|
+
longTexts: nextLongTexts,
|
|
269
|
+
revision: current.revision + 1,
|
|
270
|
+
});
|
|
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();
|
|
276
|
+
for (const listener of this.#composerListeners) {
|
|
277
|
+
try {
|
|
278
|
+
listener(next);
|
|
279
|
+
}
|
|
280
|
+
catch (cause) {
|
|
281
|
+
this.#reportUnhandledError({ cause, source: "listener" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
subscribeComposerDraft(listener) {
|
|
287
|
+
if (this.#disposed)
|
|
288
|
+
return { closed: true, dispose: () => undefined };
|
|
289
|
+
this.#composerListeners.add(listener);
|
|
290
|
+
let active = true;
|
|
291
|
+
return {
|
|
292
|
+
get closed() {
|
|
293
|
+
return !active;
|
|
294
|
+
},
|
|
295
|
+
dispose: () => {
|
|
296
|
+
if (!active)
|
|
297
|
+
return;
|
|
298
|
+
active = false;
|
|
299
|
+
this.#composerListeners.delete(listener);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async sendComposerDraft(input) {
|
|
304
|
+
const inFlightKey = input.clientMessageId === undefined
|
|
305
|
+
? undefined
|
|
306
|
+
: `${input.conversationId.length}:${input.conversationId}:${input.clientMessageId}`;
|
|
307
|
+
if (inFlightKey !== undefined) {
|
|
308
|
+
const existing = this.#composerSendsInFlight.get(inFlightKey);
|
|
309
|
+
if (existing !== undefined)
|
|
310
|
+
return existing;
|
|
311
|
+
}
|
|
312
|
+
const draft = this.getComposerDraft(input.conversationId);
|
|
313
|
+
const operation = (async () => {
|
|
314
|
+
const result = await this.sendMessage({
|
|
315
|
+
conversationId: input.conversationId,
|
|
316
|
+
deadlineAt: input.deadlineAt,
|
|
317
|
+
text: resolveComposerDraftText(draft),
|
|
318
|
+
attachments: draft.attachments,
|
|
319
|
+
...(input.clientMessageId === undefined
|
|
320
|
+
? {}
|
|
321
|
+
: { clientMessageId: input.clientMessageId }),
|
|
322
|
+
});
|
|
323
|
+
if (result.ok && this.getComposerDraft(input.conversationId) === draft) {
|
|
324
|
+
this.setComposerDraft({
|
|
325
|
+
conversationId: input.conversationId,
|
|
326
|
+
text: "",
|
|
327
|
+
attachments: [],
|
|
328
|
+
longTexts: [],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
})();
|
|
333
|
+
if (inFlightKey !== undefined) {
|
|
334
|
+
this.#composerSendsInFlight.set(inFlightKey, operation);
|
|
335
|
+
const clearInFlight = () => {
|
|
336
|
+
if (this.#composerSendsInFlight.get(inFlightKey) === operation) {
|
|
337
|
+
this.#composerSendsInFlight.delete(inFlightKey);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
void operation.then(clearInFlight, clearInFlight);
|
|
341
|
+
}
|
|
342
|
+
return operation;
|
|
343
|
+
}
|
|
84
344
|
/**
|
|
85
345
|
* Retires the current top-level conversation load without changing an
|
|
86
346
|
* already committed snapshot. Once a snapshot commits, this is a no-op and
|
|
87
347
|
* that load completes successfully while transport cleanup settles.
|
|
88
348
|
*/
|
|
89
349
|
cancelPendingConversationLoad() {
|
|
350
|
+
this.#cacheRequestId += 1;
|
|
90
351
|
if (this.#disposed || this.#pendingLoadRequestId === undefined)
|
|
91
352
|
return;
|
|
92
353
|
this.#loadRequestId += 1;
|
|
@@ -192,6 +453,25 @@ export class ChatClient {
|
|
|
192
453
|
result.value.deletedConversationId !== parsed.data.conversationId) {
|
|
193
454
|
return runtimeFailure("validation", "Gateway deleted a different conversation", parsed.data.conversationId);
|
|
194
455
|
}
|
|
456
|
+
const cacheClear = result.ok
|
|
457
|
+
? this.#cache
|
|
458
|
+
?.clear(parsed.data.conversationId, parsed.data)
|
|
459
|
+
.catch(() => undefined)
|
|
460
|
+
: undefined;
|
|
461
|
+
if (result.ok) {
|
|
462
|
+
if (this.#cacheState.conversationId === parsed.data.conversationId)
|
|
463
|
+
this.#cacheRequestId += 1;
|
|
464
|
+
this.#discardComposerDraft(parsed.data.conversationId);
|
|
465
|
+
const sendKeyPrefix = `${parsed.data.conversationId.length}:${parsed.data.conversationId}:`;
|
|
466
|
+
for (const key of this.#composerSendsInFlight.keys()) {
|
|
467
|
+
if (key.startsWith(sendKeyPrefix))
|
|
468
|
+
this.#composerSendsInFlight.delete(key);
|
|
469
|
+
}
|
|
470
|
+
for (const key of this.#messageSendsInFlight.keys()) {
|
|
471
|
+
if (key.startsWith(sendKeyPrefix))
|
|
472
|
+
this.#messageSendsInFlight.delete(key);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
195
475
|
if (result.ok &&
|
|
196
476
|
this.#pendingLoadConversationId === parsed.data.conversationId) {
|
|
197
477
|
this.#loadRequestId += 1;
|
|
@@ -220,9 +500,144 @@ export class ChatClient {
|
|
|
220
500
|
this.#snapshotStore.clear();
|
|
221
501
|
await this.#runSubscriptionCleanup(() => subscription?.dispose(parsed.data), parsed.data.conversationId);
|
|
222
502
|
}
|
|
503
|
+
await cacheClear;
|
|
223
504
|
return result;
|
|
224
505
|
}
|
|
225
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) {
|
|
226
641
|
if (this.#disposed) {
|
|
227
642
|
return runtimeFailure("conflict", "ChatClient has already been disposed", input.conversationId);
|
|
228
643
|
}
|
|
@@ -261,7 +676,10 @@ export class ChatClient {
|
|
|
261
676
|
await this.#discardGatewaySubscription(subscribed, input);
|
|
262
677
|
return runtimeFailure("conflict", "Conversation load was superseded", input.conversationId);
|
|
263
678
|
}
|
|
264
|
-
|
|
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
|
+
}
|
|
265
683
|
if (!loaded.ok) {
|
|
266
684
|
this.#clearPendingLoad(requestId);
|
|
267
685
|
await this.#discardGatewaySubscription(subscribed, input);
|
|
@@ -310,9 +728,11 @@ export class ChatClient {
|
|
|
310
728
|
this.#pendingLoadConversationId === input.conversationId
|
|
311
729
|
? this.#pendingLoadConversationOverride
|
|
312
730
|
: undefined;
|
|
731
|
+
const cached = this.#cache?.peek(input.conversationId);
|
|
732
|
+
const loadedWithHistory = mergeCachedHistory(loaded.value, cached?.snapshot);
|
|
313
733
|
const loadedState = createSnapshotState(conversationOverride === undefined
|
|
314
|
-
?
|
|
315
|
-
: { ...
|
|
734
|
+
? loadedWithHistory
|
|
735
|
+
: { ...loadedWithHistory, conversation: conversationOverride });
|
|
316
736
|
const currentState = this.#snapshotStore.state;
|
|
317
737
|
const handoff = this.#pendingHandoff;
|
|
318
738
|
let nextState = handoff?.requestId === requestId && currentState !== null
|
|
@@ -327,6 +747,7 @@ export class ChatClient {
|
|
|
327
747
|
nextState = this.#guardInteractionTransition(currentState, nextState);
|
|
328
748
|
}
|
|
329
749
|
this.#clearPendingLoad(requestId);
|
|
750
|
+
this.#cacheReadOnly = false;
|
|
330
751
|
this.#snapshotStore.commit(nextState);
|
|
331
752
|
if (!this.#disposed)
|
|
332
753
|
notificationQueue.activate(generation);
|
|
@@ -347,6 +768,58 @@ export class ChatClient {
|
|
|
347
768
|
this.#reportError(result.error, generation, "command");
|
|
348
769
|
return result;
|
|
349
770
|
}
|
|
771
|
+
async sendMessage(input) {
|
|
772
|
+
const parsed = sendMessageInputSchema.safeParse(input);
|
|
773
|
+
if (!parsed.success) {
|
|
774
|
+
return runtimeFailure("validation", "Message input is invalid", typeof input.conversationId === "string"
|
|
775
|
+
? input.conversationId
|
|
776
|
+
: undefined);
|
|
777
|
+
}
|
|
778
|
+
const command = parsed.data;
|
|
779
|
+
const attachments = command.attachments ?? [];
|
|
780
|
+
if (attachments.length === 0) {
|
|
781
|
+
return this.sendText({
|
|
782
|
+
conversationId: command.conversationId,
|
|
783
|
+
deadlineAt: command.deadlineAt,
|
|
784
|
+
text: command.text ?? "",
|
|
785
|
+
...(command.clientMessageId === undefined
|
|
786
|
+
? {}
|
|
787
|
+
: { clientMessageId: command.clientMessageId }),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const state = this.#activeState(command.conversationId);
|
|
791
|
+
if (!state.ok)
|
|
792
|
+
return state;
|
|
793
|
+
if (!isGatewayOperationSupported(state.value.capabilities, "sendAttachments") ||
|
|
794
|
+
this.#gateway.sendMessage === undefined) {
|
|
795
|
+
return runtimeFailure("unsupported", "Attachment sending is unavailable", command.conversationId);
|
|
796
|
+
}
|
|
797
|
+
const inFlightKey = command.clientMessageId === undefined
|
|
798
|
+
? undefined
|
|
799
|
+
: `${command.conversationId.length}:${command.conversationId}:${command.clientMessageId}`;
|
|
800
|
+
if (inFlightKey !== undefined) {
|
|
801
|
+
const existing = this.#messageSendsInFlight.get(inFlightKey);
|
|
802
|
+
if (existing !== undefined)
|
|
803
|
+
return existing;
|
|
804
|
+
}
|
|
805
|
+
const generation = this.#generation;
|
|
806
|
+
const operation = (async () => {
|
|
807
|
+
const result = await this.#gateway.sendMessage(command);
|
|
808
|
+
if (!result.ok && !this.#disposed)
|
|
809
|
+
this.#reportError(result.error, generation, "command");
|
|
810
|
+
return result;
|
|
811
|
+
})();
|
|
812
|
+
if (inFlightKey !== undefined) {
|
|
813
|
+
this.#messageSendsInFlight.set(inFlightKey, operation);
|
|
814
|
+
const clearInFlight = () => {
|
|
815
|
+
if (this.#messageSendsInFlight.get(inFlightKey) === operation) {
|
|
816
|
+
this.#messageSendsInFlight.delete(inFlightKey);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
void operation.then(clearInFlight, clearInFlight);
|
|
820
|
+
}
|
|
821
|
+
return operation;
|
|
822
|
+
}
|
|
350
823
|
async answerInteraction(input) {
|
|
351
824
|
const parsed = answerInteractionInputSchema.safeParse(input);
|
|
352
825
|
if (!parsed.success) {
|
|
@@ -445,6 +918,10 @@ export class ChatClient {
|
|
|
445
918
|
dispose(options) {
|
|
446
919
|
if (this.#disposePromise !== undefined)
|
|
447
920
|
return this.#disposePromise;
|
|
921
|
+
this.#captureCache(options);
|
|
922
|
+
const cacheClosed = this.#cache?.close();
|
|
923
|
+
this.#cacheListeners.clear();
|
|
924
|
+
this.#cacheRequestId += 1;
|
|
448
925
|
this.#disposed = true;
|
|
449
926
|
this.#conversationEpoch += 1;
|
|
450
927
|
this.#generation += 1;
|
|
@@ -452,6 +929,10 @@ export class ChatClient {
|
|
|
452
929
|
this.#interactionAnswersInFlight.clear();
|
|
453
930
|
this.#retiredInteractionIdentities.clear();
|
|
454
931
|
this.#supersededInteractionAnswers.clear();
|
|
932
|
+
this.#composerSendsInFlight.clear();
|
|
933
|
+
this.#messageSendsInFlight.clear();
|
|
934
|
+
this.#composerDrafts.clear();
|
|
935
|
+
this.#composerListeners.clear();
|
|
455
936
|
this.#loadRequestId += 1;
|
|
456
937
|
this.#pendingLoadConversationId = undefined;
|
|
457
938
|
this.#pendingLoadConversationOverride = undefined;
|
|
@@ -461,6 +942,7 @@ export class ChatClient {
|
|
|
461
942
|
const subscription = this.#gatewaySubscription;
|
|
462
943
|
this.#gatewaySubscription = undefined;
|
|
463
944
|
this.#disposePromise = Promise.allSettled([
|
|
945
|
+
cacheClosed,
|
|
464
946
|
Promise.resolve().then(() => subscription?.dispose(options)),
|
|
465
947
|
Promise.resolve().then(() => this.#gateway.dispose(options)),
|
|
466
948
|
]).then((results) => {
|
|
@@ -473,6 +955,42 @@ export class ChatClient {
|
|
|
473
955
|
});
|
|
474
956
|
return this.#disposePromise;
|
|
475
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
|
+
}
|
|
476
994
|
async #loadHistory(input) {
|
|
477
995
|
const active = this.#activeState(input.conversationId);
|
|
478
996
|
if (!active.ok)
|
|
@@ -534,6 +1052,8 @@ export class ChatClient {
|
|
|
534
1052
|
if (this.#snapshotStore.state?.snapshot.conversation.id !== conversationId) {
|
|
535
1053
|
return runtimeFailure("conflict", "Conversation is not active in this ChatClient", conversationId);
|
|
536
1054
|
}
|
|
1055
|
+
if (this.#cacheReadOnly)
|
|
1056
|
+
return runtimeFailure("conflict", "Cached conversation is read-only until synchronization succeeds", conversationId);
|
|
537
1057
|
const lifecycle = this.#snapshotStore.state.snapshot.lifecycle;
|
|
538
1058
|
if (lifecycle !== undefined && !isChatLifecycleOperable(lifecycle)) {
|
|
539
1059
|
return runtimeFailure(lifecycle.status === "auth-required"
|
|
@@ -624,7 +1144,15 @@ export class ChatClient {
|
|
|
624
1144
|
return;
|
|
625
1145
|
}
|
|
626
1146
|
const state = this.#snapshotStore.latestState();
|
|
627
|
-
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)));
|
|
628
1156
|
}
|
|
629
1157
|
#guardInteractionTransition(current, candidate) {
|
|
630
1158
|
const currentInteraction = current.snapshot.pendingInteraction;
|