@tanstack/ai-client 0.15.2 → 0.16.2
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/esm/chat-client.d.ts +9 -0
- package/dist/esm/chat-client.js +92 -17
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/client-persistor.d.ts +86 -0
- package/dist/esm/client-persistor.js +243 -0
- package/dist/esm/client-persistor.js.map +1 -0
- package/dist/esm/connection-adapters.d.ts +6 -0
- package/dist/esm/connection-adapters.js +10 -2
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/types.d.ts +9 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +112 -28
- package/src/client-persistor.ts +337 -0
- package/src/connection-adapters.ts +25 -2
- package/src/index.ts +1 -0
- package/src/types.ts +22 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { getChunkRunId } from './connection-adapters'
|
|
2
|
+
import type { StreamChunk } from '@tanstack/ai/client'
|
|
3
|
+
import type { ChatClientPersistence, UIMessage } from './types'
|
|
4
|
+
|
|
5
|
+
// `StreamChunk` is a discriminated union; `toolCallId` / `messageId` /
|
|
6
|
+
// `parentMessageId` exist on only some members. Narrow with `in` (matching
|
|
7
|
+
// `getChunkRunId`) instead of asserting a shape, so the field's real type is
|
|
8
|
+
// preserved and a protocol rename can't be read past silently.
|
|
9
|
+
function getChunkToolCallId(chunk: StreamChunk): string | undefined {
|
|
10
|
+
return 'toolCallId' in chunk && typeof chunk.toolCallId === 'string'
|
|
11
|
+
? chunk.toolCallId
|
|
12
|
+
: undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getChunkMessageId(chunk: StreamChunk): string | undefined {
|
|
16
|
+
return 'messageId' in chunk && typeof chunk.messageId === 'string'
|
|
17
|
+
? chunk.messageId
|
|
18
|
+
: undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getChunkParentMessageId(chunk: StreamChunk): string | undefined {
|
|
22
|
+
return 'parentMessageId' in chunk && typeof chunk.parentMessageId === 'string'
|
|
23
|
+
? chunk.parentMessageId
|
|
24
|
+
: undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encapsulates everything persistence-related for `ChatClient` so the client
|
|
29
|
+
* itself stays focused on streaming and message state.
|
|
30
|
+
*
|
|
31
|
+
* Two responsibilities live here:
|
|
32
|
+
*
|
|
33
|
+
* 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save to
|
|
34
|
+
* `setItem(id, messages)` on every change through an ordered write queue, and
|
|
35
|
+
* `removeItem(id)` on clear. A generation counter discards stale writes when a
|
|
36
|
+
* removal or a newer conversation supersedes an in-flight async operation.
|
|
37
|
+
* 2. **Clear-during-stream suppression** — when a conversation is cleared while a
|
|
38
|
+
* stream is still producing, late chunks for the cleared run(s) must not
|
|
39
|
+
* repopulate the now-empty state. The persistor tracks the cleared ids and
|
|
40
|
+
* decides, per chunk, whether the client should ignore it.
|
|
41
|
+
*
|
|
42
|
+
* All adapter calls are best-effort: a throwing or rejecting adapter is swallowed
|
|
43
|
+
* so storage problems never break the chat.
|
|
44
|
+
*/
|
|
45
|
+
export class ChatPersistor {
|
|
46
|
+
// --- storage queue state ---
|
|
47
|
+
private skipNextPersist = false
|
|
48
|
+
private generation = 0
|
|
49
|
+
private queue: Promise<void> = Promise.resolve()
|
|
50
|
+
private queuePending = false
|
|
51
|
+
// Bumped on every message change; lets an in-flight async hydration detect
|
|
52
|
+
// that the message list moved on and avoid clobbering it.
|
|
53
|
+
private messagesGeneration = 0
|
|
54
|
+
|
|
55
|
+
// --- clear-during-stream suppression state ---
|
|
56
|
+
private readonly clearedMessageIds = new Set<string>()
|
|
57
|
+
private readonly clearedRunIds = new Set<string>()
|
|
58
|
+
private readonly ignoredActiveRunIds = new Set<string>()
|
|
59
|
+
private readonly clearedToolCallIds = new Set<string>()
|
|
60
|
+
private currentRunlessRunId: string | null = null
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly adapter: ChatClientPersistence,
|
|
64
|
+
private readonly id: string,
|
|
65
|
+
private readonly applyMessages: (messages: Array<UIMessage>) => void,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Storage orchestration
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Synchronously read the persisted messages for constructor-time hydration.
|
|
74
|
+
* Returns the raw `getItem` result (which may be a promise for async stores).
|
|
75
|
+
*/
|
|
76
|
+
readInitial():
|
|
77
|
+
| Array<UIMessage>
|
|
78
|
+
| null
|
|
79
|
+
| undefined
|
|
80
|
+
| Promise<Array<UIMessage> | null | undefined> {
|
|
81
|
+
try {
|
|
82
|
+
return this.adapter.getItem(this.id)
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply messages from an async `getItem` once it resolves, unless the message
|
|
90
|
+
* list has already changed since hydration began.
|
|
91
|
+
*/
|
|
92
|
+
hydrateAsync(
|
|
93
|
+
persistedMessages:
|
|
94
|
+
| Array<UIMessage>
|
|
95
|
+
| null
|
|
96
|
+
| undefined
|
|
97
|
+
| Promise<Array<UIMessage> | null | undefined>,
|
|
98
|
+
): void {
|
|
99
|
+
if (!(persistedMessages instanceof Promise)) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hydrationGeneration = this.messagesGeneration
|
|
104
|
+
persistedMessages
|
|
105
|
+
.then((messages) => {
|
|
106
|
+
if (
|
|
107
|
+
Array.isArray(messages) &&
|
|
108
|
+
this.messagesGeneration === hydrationGeneration
|
|
109
|
+
) {
|
|
110
|
+
this.applyMessages(messages)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
// Persistence adapters are best-effort and must not break chat setup.
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Record a message-list change and queue a `setItem` write for it. Skips a
|
|
120
|
+
* single write after {@link beginClear} so the clear's empty snapshot isn't
|
|
121
|
+
* persisted between `clearMessages()` and {@link remove}.
|
|
122
|
+
*/
|
|
123
|
+
notifyMessagesChanged(messages: Array<UIMessage>): void {
|
|
124
|
+
this.messagesGeneration++
|
|
125
|
+
if (this.skipNextPersist) {
|
|
126
|
+
this.skipNextPersist = false
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const generation = this.generation
|
|
130
|
+
const messagesSnapshot = [...messages]
|
|
131
|
+
this.runOperation(() => {
|
|
132
|
+
if (generation !== this.generation) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
return this.adapter.setItem(this.id, messagesSnapshot)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Remove the persisted conversation. Invalidates any queued writes. */
|
|
140
|
+
remove(): void {
|
|
141
|
+
const generation = ++this.generation
|
|
142
|
+
this.runOperation(() => {
|
|
143
|
+
if (generation !== this.generation) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
return this.adapter.removeItem(this.id)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private runOperation(operation: () => void | Promise<void>): void {
|
|
151
|
+
if (this.queuePending) {
|
|
152
|
+
const queued = this.queue.then(operation).catch(() => {
|
|
153
|
+
// Persistence adapters are best-effort and must not break chat updates.
|
|
154
|
+
})
|
|
155
|
+
this.queue = queued
|
|
156
|
+
void queued.finally(() => {
|
|
157
|
+
if (this.queue === queued) {
|
|
158
|
+
this.queuePending = false
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = operation()
|
|
166
|
+
if (result instanceof Promise) {
|
|
167
|
+
this.queuePending = true
|
|
168
|
+
const queued = result.catch(() => {
|
|
169
|
+
// Persistence adapters are best-effort and must not break chat updates.
|
|
170
|
+
})
|
|
171
|
+
this.queue = queued
|
|
172
|
+
void queued.finally(() => {
|
|
173
|
+
if (this.queue === queued) {
|
|
174
|
+
this.queuePending = false
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Persistence adapters are best-effort and must not break chat updates.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Clear-during-stream suppression
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Capture the message/run ids that exist at the moment of a clear so chunks
|
|
189
|
+
* still arriving for them can be ignored.
|
|
190
|
+
*/
|
|
191
|
+
snapshotClear(context: {
|
|
192
|
+
messages: Array<UIMessage>
|
|
193
|
+
activeRunIds: Set<string>
|
|
194
|
+
currentRunId: string | null
|
|
195
|
+
}): void {
|
|
196
|
+
for (const message of context.messages) {
|
|
197
|
+
this.clearedMessageIds.add(message.id)
|
|
198
|
+
}
|
|
199
|
+
for (const runId of context.activeRunIds) {
|
|
200
|
+
this.clearedRunIds.add(runId)
|
|
201
|
+
this.ignoredActiveRunIds.add(runId)
|
|
202
|
+
}
|
|
203
|
+
if (context.currentRunId) {
|
|
204
|
+
this.clearedRunIds.add(context.currentRunId)
|
|
205
|
+
this.ignoredActiveRunIds.add(context.currentRunId)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Mark that the next persisted message change (the clear itself) is skipped. */
|
|
210
|
+
beginClear(): void {
|
|
211
|
+
this.skipNextPersist = true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Whether a chunk belongs to cleared state and should not be processed. */
|
|
215
|
+
shouldIgnoreChunk(chunk: StreamChunk): boolean {
|
|
216
|
+
const runId = getChunkRunId(chunk)
|
|
217
|
+
if (runId && this.clearedRunIds.has(runId)) {
|
|
218
|
+
if (chunk.type === 'RUN_STARTED') {
|
|
219
|
+
this.ignoredActiveRunIds.add(runId)
|
|
220
|
+
this.currentRunlessRunId = runId
|
|
221
|
+
}
|
|
222
|
+
this.markIgnoredChunkIds(chunk)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (runId && this.ignoredActiveRunIds.has(runId)) {
|
|
227
|
+
this.markIgnoredChunkIds(chunk)
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.isRunlessChunkFromIgnoredRun(chunk)) {
|
|
232
|
+
this.markIgnoredChunkIds(chunk)
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const toolCallId = getChunkToolCallId(chunk)
|
|
237
|
+
if (toolCallId && this.clearedToolCallIds.has(toolCallId)) {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parentMessageId = getChunkParentMessageId(chunk)
|
|
242
|
+
if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) {
|
|
243
|
+
if (toolCallId) {
|
|
244
|
+
this.clearedToolCallIds.add(toolCallId)
|
|
245
|
+
}
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const messageId = getChunkMessageId(chunk)
|
|
250
|
+
if (!messageId) {
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
if (this.clearedMessageIds.has(messageId)) {
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The owning client calls this when a run starts so runless content chunks
|
|
262
|
+
* (adapters that omit `runId` on content events) can be attributed to it.
|
|
263
|
+
*/
|
|
264
|
+
onRunStarted(runId: string): void {
|
|
265
|
+
this.currentRunlessRunId = runId
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Forget a settled run, advancing the runless pointer to another ignored run. */
|
|
269
|
+
onRunSettled(runId: string): void {
|
|
270
|
+
this.ignoredActiveRunIds.delete(runId)
|
|
271
|
+
this.clearedRunIds.delete(runId)
|
|
272
|
+
if (this.currentRunlessRunId === runId) {
|
|
273
|
+
this.currentRunlessRunId =
|
|
274
|
+
this.ignoredActiveRunIds.values().next().value ?? null
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A session-level (runId-less) RUN_ERROR clears all ignored-run tracking. */
|
|
279
|
+
onSessionRunError(): void {
|
|
280
|
+
this.ignoredActiveRunIds.clear()
|
|
281
|
+
this.currentRunlessRunId = null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Clear the ignored-active-run markers (mirrors a session-generating reset). */
|
|
285
|
+
resetIgnored(): void {
|
|
286
|
+
this.ignoredActiveRunIds.clear()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Consume the current runless run id (if any), forgetting it. Used when an
|
|
291
|
+
* ignored, runId-less RUN_ERROR drains the run the client is still tracking.
|
|
292
|
+
*/
|
|
293
|
+
takeRunlessRunId(): string | null {
|
|
294
|
+
const runId = this.currentRunlessRunId
|
|
295
|
+
if (!runId) return null
|
|
296
|
+
this.ignoredActiveRunIds.delete(runId)
|
|
297
|
+
this.clearedRunIds.delete(runId)
|
|
298
|
+
// Advance to another still-ignored run (mirroring `onRunSettled`) so that
|
|
299
|
+
// when two cleared runs drain concurrently, draining one via a runId-less
|
|
300
|
+
// RUN_ERROR doesn't stop suppressing the other's runless content.
|
|
301
|
+
this.currentRunlessRunId =
|
|
302
|
+
this.ignoredActiveRunIds.values().next().value ?? null
|
|
303
|
+
return runId
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private markIgnoredChunkIds(chunk: StreamChunk): void {
|
|
307
|
+
const messageId = getChunkMessageId(chunk)
|
|
308
|
+
if (messageId) {
|
|
309
|
+
this.clearedMessageIds.add(messageId)
|
|
310
|
+
}
|
|
311
|
+
const toolCallId = getChunkToolCallId(chunk)
|
|
312
|
+
if (toolCallId) {
|
|
313
|
+
this.clearedToolCallIds.add(toolCallId)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean {
|
|
318
|
+
const runId = getChunkRunId(chunk)
|
|
319
|
+
if (runId || !this.currentRunlessRunId) return false
|
|
320
|
+
if (
|
|
321
|
+
!this.ignoredActiveRunIds.has(this.currentRunlessRunId) &&
|
|
322
|
+
!this.clearedRunIds.has(this.currentRunlessRunId)
|
|
323
|
+
) {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
return (
|
|
327
|
+
chunk.type === 'TEXT_MESSAGE_START' ||
|
|
328
|
+
chunk.type === 'TEXT_MESSAGE_CONTENT' ||
|
|
329
|
+
chunk.type === 'TOOL_CALL_START' ||
|
|
330
|
+
chunk.type === 'TOOL_CALL_ARGS' ||
|
|
331
|
+
chunk.type === 'TOOL_CALL_END' ||
|
|
332
|
+
chunk.type === 'TOOL_CALL_RESULT' ||
|
|
333
|
+
chunk.type === 'MESSAGES_SNAPSHOT' ||
|
|
334
|
+
chunk.type === 'RUN_ERROR'
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -13,6 +13,26 @@ import type {
|
|
|
13
13
|
} from '@tanstack/ai/client'
|
|
14
14
|
import type { ChatFetcher } from './types'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Associates connect-wrapped chunks with the run they were produced under.
|
|
18
|
+
* Content events (TEXT_MESSAGE_CONTENT, TOOL_CALL_*, …) carry no `runId` of
|
|
19
|
+
* their own, so the connect wrapper stamps the caller's run id here. Lets
|
|
20
|
+
* run-scoped consumers (e.g. clear-during-stream suppression) attribute those
|
|
21
|
+
* otherwise-runless chunks to their originating request.
|
|
22
|
+
*/
|
|
23
|
+
const chunkRunIds = new WeakMap<StreamChunk, string>()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a chunk's run id, preferring the value on the chunk itself
|
|
27
|
+
* (RUN_STARTED / RUN_FINISHED / RUN_ERROR carry one) and falling back to the
|
|
28
|
+
* run the connect wrapper stamped it with.
|
|
29
|
+
*/
|
|
30
|
+
export function getChunkRunId(chunk: StreamChunk): string | undefined {
|
|
31
|
+
return 'runId' in chunk && typeof chunk.runId === 'string'
|
|
32
|
+
? chunk.runId
|
|
33
|
+
: chunkRunIds.get(chunk)
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
/**
|
|
17
37
|
* Thrown when an SSE/HTTP stream ends with a non-empty unterminated buffer.
|
|
18
38
|
* Indicates the connection was cut mid-line (server crash, dropped TCP, proxy
|
|
@@ -265,7 +285,10 @@ export function normalizeConnectionAdapter(
|
|
|
265
285
|
let activeBuffer: Array<StreamChunk> = []
|
|
266
286
|
let activeWaiters: Array<(chunk: StreamChunk | null) => void> = []
|
|
267
287
|
|
|
268
|
-
function push(chunk: StreamChunk): void {
|
|
288
|
+
function push(chunk: StreamChunk, runId?: string): void {
|
|
289
|
+
if (runId) {
|
|
290
|
+
chunkRunIds.set(chunk, runId)
|
|
291
|
+
}
|
|
269
292
|
const waiter = activeWaiters.shift()
|
|
270
293
|
if (waiter) {
|
|
271
294
|
waiter(chunk)
|
|
@@ -324,7 +347,7 @@ export function normalizeConnectionAdapter(
|
|
|
324
347
|
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
325
348
|
hasTerminalEvent = true
|
|
326
349
|
}
|
|
327
|
-
push(chunk)
|
|
350
|
+
push(chunk, runContext?.runId)
|
|
328
351
|
}
|
|
329
352
|
|
|
330
353
|
// If the connect stream ended cleanly without a terminal event,
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -266,6 +266,23 @@ export interface UIMessage<
|
|
|
266
266
|
createdAt?: Date
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
export interface ChatClientPersistence<
|
|
270
|
+
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
271
|
+
> {
|
|
272
|
+
getItem: (
|
|
273
|
+
id: string,
|
|
274
|
+
) =>
|
|
275
|
+
| Array<UIMessage<TTools>>
|
|
276
|
+
| null
|
|
277
|
+
| undefined
|
|
278
|
+
| Promise<Array<UIMessage<TTools>> | null | undefined>
|
|
279
|
+
setItem: (
|
|
280
|
+
id: string,
|
|
281
|
+
messages: Array<UIMessage<TTools>>,
|
|
282
|
+
) => void | Promise<void>
|
|
283
|
+
removeItem: (id: string) => void | Promise<void>
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
type IsUnknown<T> = unknown extends T
|
|
270
287
|
? [T] extends [unknown]
|
|
271
288
|
? true
|
|
@@ -356,6 +373,11 @@ export interface ChatClientBaseOptions<
|
|
|
356
373
|
*/
|
|
357
374
|
initialMessages?: Array<UIMessage<TTools>>
|
|
358
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Optional persistence adapter for chat messages.
|
|
378
|
+
*/
|
|
379
|
+
persistence?: ChatClientPersistence<TTools>
|
|
380
|
+
|
|
359
381
|
/**
|
|
360
382
|
* Unique identifier for this chat instance
|
|
361
383
|
* Used for managing multiple chats
|