@tanstack/ai-client 0.22.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/esm/audio-recorder.js +190 -213
- package/dist/esm/audio-recorder.js.map +1 -1
- package/dist/esm/chat-client.d.ts +172 -3
- package/dist/esm/chat-client.js +1656 -1386
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/cleared-stream-tracker.d.ts +23 -0
- package/dist/esm/cleared-stream-tracker.js +97 -0
- package/dist/esm/cleared-stream-tracker.js.map +1 -0
- package/dist/esm/client-persistor.d.ts +25 -12
- package/dist/esm/client-persistor.js +260 -235
- package/dist/esm/client-persistor.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +231 -10
- package/dist/esm/connection-adapters.js +989 -574
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +79 -139
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +31 -1
- package/dist/esm/devtools.js +977 -1127
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/events.js +224 -226
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +145 -2
- package/dist/esm/generation-client.js +659 -321
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-reconstruct.d.ts +21 -0
- package/dist/esm/generation-reconstruct.js +85 -0
- package/dist/esm/generation-reconstruct.js.map +1 -0
- package/dist/esm/generation-types.d.ts +289 -3
- package/dist/esm/generation-types.js +356 -13
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +9 -4
- package/dist/esm/index.js +7 -39
- package/dist/esm/interrupt-manager.d.ts +77 -0
- package/dist/esm/interrupt-manager.js +787 -0
- package/dist/esm/interrupt-manager.js.map +1 -0
- package/dist/esm/mcp-app-bridge.js +56 -64
- package/dist/esm/mcp-app-bridge.js.map +1 -1
- package/dist/esm/realtime-client.js +366 -440
- package/dist/esm/realtime-client.js.map +1 -1
- package/dist/esm/response-stream.js +19 -26
- package/dist/esm/response-stream.js.map +1 -1
- package/dist/esm/sse-parser.js +44 -47
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/sse-utils.js +8 -9
- package/dist/esm/sse-utils.js.map +1 -1
- package/dist/esm/storage-adapters.d.ts +62 -0
- package/dist/esm/storage-adapters.js +174 -0
- package/dist/esm/storage-adapters.js.map +1 -0
- package/dist/esm/types.d.ts +212 -10
- package/dist/esm/types.js +38 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +113 -2
- package/dist/esm/video-generation-client.js +665 -379
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -7
- package/src/chat-client.ts +1079 -61
- package/src/cleared-stream-tracker.ts +151 -0
- package/src/client-persistor.ts +102 -33
- package/src/connection-adapters.ts +1185 -142
- package/src/devtools-noop.ts +4 -3
- package/src/devtools.ts +121 -3
- package/src/generation-client.ts +563 -13
- package/src/generation-reconstruct.ts +121 -0
- package/src/generation-types.ts +727 -3
- package/src/index.ts +56 -1
- package/src/interrupt-manager.ts +1440 -0
- package/src/storage-adapters.ts +242 -0
- package/src/types.ts +301 -9
- package/src/video-generation-client.ts +479 -13
- package/dist/esm/index.js.map +0 -1
package/src/generation-client.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
GENERATION_EVENTS,
|
|
3
|
+
GENERATION_STREAM_TRUNCATED_MESSAGE,
|
|
4
|
+
GENERATION_UNRESTORABLE_RESULT_MESSAGE,
|
|
5
|
+
clientStateFromResumeStatus,
|
|
6
|
+
createGenerationHydrationError,
|
|
7
|
+
createGenerationResultSnapshot,
|
|
8
|
+
parseGenerationResumeSnapshot,
|
|
9
|
+
updateGenerationResumeSnapshot,
|
|
10
|
+
} from './generation-types'
|
|
2
11
|
import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
|
|
3
12
|
import { parseSSEResponse } from './sse-parser'
|
|
4
13
|
import type { StreamChunk } from '@tanstack/ai/client'
|
|
5
14
|
import type {
|
|
6
15
|
ConnectConnectionAdapter,
|
|
16
|
+
GenerationHydrationResult,
|
|
7
17
|
RunAgentInputContext,
|
|
8
18
|
} from './connection-adapters'
|
|
9
19
|
import type {
|
|
@@ -16,6 +26,9 @@ import type {
|
|
|
16
26
|
GenerationClientOptions,
|
|
17
27
|
GenerationClientState,
|
|
18
28
|
GenerationFetcher,
|
|
29
|
+
GenerationRestoredResult,
|
|
30
|
+
GenerationResumeSnapshot,
|
|
31
|
+
GenerationResumeState,
|
|
19
32
|
} from './generation-types'
|
|
20
33
|
|
|
21
34
|
/**
|
|
@@ -33,6 +46,15 @@ interface GenerationCallbacks<TResult, TOutput> {
|
|
|
33
46
|
onLoadingChange?: ((isLoading: boolean) => void) | undefined
|
|
34
47
|
onErrorChange?: ((error: Error | undefined) => void) | undefined
|
|
35
48
|
onStatusChange?: ((status: GenerationClientState) => void) | undefined
|
|
49
|
+
onResumeSnapshotChange?:
|
|
50
|
+
| ((snapshot: GenerationResumeSnapshot | undefined) => void)
|
|
51
|
+
| undefined
|
|
52
|
+
onResumeStateChange?:
|
|
53
|
+
| ((resumeState: GenerationResumeState | null) => void)
|
|
54
|
+
| undefined
|
|
55
|
+
reconstructResult?:
|
|
56
|
+
| ((restored: GenerationRestoredResult) => TResult | null)
|
|
57
|
+
| undefined
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
/**
|
|
@@ -77,10 +99,23 @@ export class GenerationClient<
|
|
|
77
99
|
> {
|
|
78
100
|
private readonly connection: ConnectConnectionAdapter | undefined
|
|
79
101
|
private readonly fetcher: GenerationFetcher<TInput, TResult> | undefined
|
|
102
|
+
// Persistence handlers supplied as options (e.g. alongside a `fetcher`), used
|
|
103
|
+
// when the connection doesn't carry its own — the connection's handlers take
|
|
104
|
+
// precedence when both exist.
|
|
105
|
+
private readonly hydrateGenerationHandler:
|
|
106
|
+
| ConnectConnectionAdapter['hydrateGeneration']
|
|
107
|
+
| undefined
|
|
108
|
+
private readonly joinRunHandler:
|
|
109
|
+
| ConnectConnectionAdapter['joinRun']
|
|
110
|
+
| undefined
|
|
80
111
|
private readonly uniqueId: string
|
|
81
112
|
private readonly devtoolsMetadata: AIDevtoolsClientMetadata
|
|
82
113
|
private readonly devtoolsBridge: GenerationDevtoolsBridge<TOutput>
|
|
83
114
|
private readonly threadId: string
|
|
115
|
+
private readonly persistenceScope: string | undefined
|
|
116
|
+
// Server-driven mode (`persistence: true`): no local snapshot store; on mount
|
|
117
|
+
// the client hydrates the last generation for `threadId` from the server.
|
|
118
|
+
private readonly serverDriven: boolean = false
|
|
84
119
|
private body: Record<string, any>
|
|
85
120
|
private result: TOutput | null = null
|
|
86
121
|
private input: TInput | null = null
|
|
@@ -88,9 +123,14 @@ export class GenerationClient<
|
|
|
88
123
|
private isLoading = false
|
|
89
124
|
private error: Error | undefined = undefined
|
|
90
125
|
private status: GenerationClientState = 'idle'
|
|
126
|
+
private resumeSnapshot: GenerationResumeSnapshot | undefined
|
|
127
|
+
private lastEmittedResumeState: string | undefined
|
|
91
128
|
private abortController: AbortController | null = null
|
|
129
|
+
private rejoinedRunId: string | undefined
|
|
92
130
|
private readonly callbacksRef: GenerationCallbacks<TResult, TOutput>
|
|
93
131
|
private devtoolsMounted = false
|
|
132
|
+
private disposed = false
|
|
133
|
+
private serverHydrationStarted = false
|
|
94
134
|
|
|
95
135
|
constructor(
|
|
96
136
|
options: GenerationClientOptions<TInput, TResult, TOutput> &
|
|
@@ -102,12 +142,35 @@ export class GenerationClient<
|
|
|
102
142
|
}
|
|
103
143
|
),
|
|
104
144
|
) {
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
// `threadId` is the single identity. Deprecated `id` is only a fallback
|
|
146
|
+
// when no threadId is given (ephemeral runs / legacy call sites).
|
|
147
|
+
this.uniqueId =
|
|
148
|
+
options.threadId ?? options.id ?? this.generateUniqueId('generation')
|
|
149
|
+
// AG-UI requires a thread id on every run, so fall back to uniqueId. The
|
|
150
|
+
// generated fallback is for the WIRE ONLY: it is not stable across reloads,
|
|
151
|
+
// so persistence must never key on it — see `persistenceScope` below.
|
|
152
|
+
this.threadId = options.threadId ?? this.uniqueId
|
|
153
|
+
// The persistence scope: the explicit `threadId` and nothing else. The
|
|
154
|
+
// types require it whenever `persistence` is set; this field keeps the
|
|
155
|
+
// fallback from silently becoming a storage key for JS callers.
|
|
156
|
+
this.persistenceScope = options.threadId
|
|
107
157
|
this.connection = options.connection
|
|
108
158
|
this.fetcher = options.fetcher
|
|
159
|
+
this.hydrateGenerationHandler = options.hydrateGeneration
|
|
160
|
+
this.joinRunHandler = options.joinRun
|
|
109
161
|
this.body = options.body ?? {}
|
|
110
|
-
|
|
162
|
+
// `persistence` is `false`/omitted (ephemeral) or `true` (server-driven:
|
|
163
|
+
// hydrate the last generation for `threadId` from the server on mount).
|
|
164
|
+
this.serverDriven = options.persistence === true
|
|
165
|
+
// The types require `threadId` alongside `persistence`, so this only fires
|
|
166
|
+
// for JS callers. Warn rather than fall back silently: keying on the
|
|
167
|
+
// generated wire id would write a different slot every reload, restoring
|
|
168
|
+
// nothing while accumulating orphaned records.
|
|
169
|
+
if (options.persistence && !this.persistenceScope) {
|
|
170
|
+
console.warn(
|
|
171
|
+
'[TanStack AI] `persistence` needs a stable `threadId` to key on. Without one nothing will be restored after a reload. Pass a `threadId` derived from your own domain (e.g. `product-123-hero`).',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
111
174
|
this.callbacksRef = {
|
|
112
175
|
onResult: options.onResult,
|
|
113
176
|
onError: options.onError,
|
|
@@ -117,12 +180,22 @@ export class GenerationClient<
|
|
|
117
180
|
onLoadingChange: options.onLoadingChange,
|
|
118
181
|
onErrorChange: options.onErrorChange,
|
|
119
182
|
onStatusChange: options.onStatusChange,
|
|
183
|
+
onResumeSnapshotChange: options.onResumeSnapshotChange,
|
|
184
|
+
onResumeStateChange: options.onResumeStateChange,
|
|
185
|
+
reconstructResult: options.reconstructResult,
|
|
120
186
|
}
|
|
121
187
|
|
|
122
188
|
this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
|
|
123
189
|
this.devtoolsBridge = (
|
|
124
190
|
options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge
|
|
125
191
|
)<TOutput>(this.buildDevtoolsBridgeOptions())
|
|
192
|
+
|
|
193
|
+
// Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework
|
|
194
|
+
// hooks build this client inside `useMemo`, so the constructor executes in
|
|
195
|
+
// React's render phase; hydrating here would re-fire the hydrate GET on
|
|
196
|
+
// every discarded/speculative render, flooding the connection pool when
|
|
197
|
+
// several clients mount together. It is kicked off once from
|
|
198
|
+
// `mountDevtools`, which the hooks call from a commit-phase mount effect.
|
|
126
199
|
}
|
|
127
200
|
|
|
128
201
|
private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions<TOutput> {
|
|
@@ -143,6 +216,19 @@ export class GenerationClient<
|
|
|
143
216
|
}
|
|
144
217
|
|
|
145
218
|
mountDevtools(): void {
|
|
219
|
+
// Mounting revives a disposed client. Framework hooks call this from
|
|
220
|
+
// their mount effect, so a dispose → remount cycle (e.g. React
|
|
221
|
+
// StrictMode's mount → cleanup → mount replay against the same memoized
|
|
222
|
+
// client) leaves the client usable again.
|
|
223
|
+
this.disposed = false
|
|
224
|
+
this.maybeHydrateFromServer()
|
|
225
|
+
// Re-attach to an in-flight run whose snapshot is already loaded — the
|
|
226
|
+
// remount case. On the FIRST mount the snapshot loads asynchronously and
|
|
227
|
+
// `repaintRestoredSnapshot` starts the rejoin; on a StrictMode remount the
|
|
228
|
+
// snapshot is already present but the prior rejoin was aborted by
|
|
229
|
+
// `dispose()`, so retrigger it here. Guarded by `rejoinInFlight`'s own
|
|
230
|
+
// dedupe/in-flight checks, so this never double-joins.
|
|
231
|
+
this.maybeResumeInFlight()
|
|
146
232
|
if (this.devtoolsMounted) {
|
|
147
233
|
return
|
|
148
234
|
}
|
|
@@ -158,8 +244,9 @@ export class GenerationClient<
|
|
|
158
244
|
* while already generating will be a no-op.
|
|
159
245
|
*/
|
|
160
246
|
async generate(input: TInput): Promise<void> {
|
|
161
|
-
this.
|
|
247
|
+
if (this.disposed) return
|
|
162
248
|
if (this.isLoading) return
|
|
249
|
+
this.mountDevtools()
|
|
163
250
|
|
|
164
251
|
this.input = input
|
|
165
252
|
this.progress = null
|
|
@@ -179,11 +266,16 @@ export class GenerationClient<
|
|
|
179
266
|
if (signal.aborted) return
|
|
180
267
|
if (result instanceof Response) {
|
|
181
268
|
// Server function returned SSE Response — parse stream
|
|
182
|
-
await this.processStream(
|
|
269
|
+
await this.processStream(
|
|
270
|
+
parseSSEResponse(result, signal),
|
|
271
|
+
runId,
|
|
272
|
+
signal,
|
|
273
|
+
)
|
|
183
274
|
} else {
|
|
184
275
|
this.devtoolsBridge.ensureRunStarted(runId)
|
|
185
276
|
this.setResult(result)
|
|
186
277
|
this.setStatus('success')
|
|
278
|
+
this.completePlainFetcherResumeSnapshot(result)
|
|
187
279
|
}
|
|
188
280
|
} else if (this.connection) {
|
|
189
281
|
// Streaming adapter path
|
|
@@ -194,7 +286,7 @@ export class GenerationClient<
|
|
|
194
286
|
signal,
|
|
195
287
|
this.createRunContext(runId),
|
|
196
288
|
)
|
|
197
|
-
await this.processStream(stream, runId)
|
|
289
|
+
await this.processStream(stream, runId, signal)
|
|
198
290
|
} else {
|
|
199
291
|
throw new Error(
|
|
200
292
|
'GenerationClient requires either a connection or fetcher option',
|
|
@@ -217,6 +309,7 @@ export class GenerationClient<
|
|
|
217
309
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
218
310
|
this.setError(error)
|
|
219
311
|
this.setStatus('error')
|
|
312
|
+
this.recordResumeSnapshotError(error)
|
|
220
313
|
this.devtoolsBridge.finishRun(
|
|
221
314
|
this.devtoolsBridge.getActiveRunId() ?? runId,
|
|
222
315
|
'run:errored',
|
|
@@ -225,24 +318,38 @@ export class GenerationClient<
|
|
|
225
318
|
)
|
|
226
319
|
this.callbacksRef.onError?.(error)
|
|
227
320
|
} finally {
|
|
228
|
-
this.abortController
|
|
229
|
-
|
|
321
|
+
if (this.abortController === abortController) {
|
|
322
|
+
this.abortController = null
|
|
323
|
+
this.setIsLoading(false)
|
|
324
|
+
}
|
|
230
325
|
}
|
|
231
326
|
}
|
|
232
327
|
|
|
233
328
|
/**
|
|
234
329
|
* Process a stream of AG-UI events from the streaming connection adapter.
|
|
330
|
+
*
|
|
331
|
+
* Throws {@link GENERATION_STREAM_TRUNCATED_MESSAGE} when the iteration ends
|
|
332
|
+
* without a terminal chunk. A `for await` over a stream that simply stops —
|
|
333
|
+
* proxy idle timeout, server restart, a durable log missing its terminal
|
|
334
|
+
* append — returns normally and would otherwise leave the caller's `status`
|
|
335
|
+
* on `generating` forever, with the persisted snapshot still `running` so
|
|
336
|
+
* every reload rejoins the same dead run. Throwing routes it through the
|
|
337
|
+
* caller's error path instead, which settles the status and rewrites the
|
|
338
|
+
* snapshot so nothing chases it again.
|
|
235
339
|
*/
|
|
236
340
|
private async processStream(
|
|
237
341
|
source: AsyncIterable<StreamChunk>,
|
|
238
342
|
fallbackRunId: string,
|
|
343
|
+
signal: AbortSignal,
|
|
239
344
|
): Promise<void> {
|
|
240
345
|
let streamRunId: string | undefined
|
|
346
|
+
let sawTerminalChunk = false
|
|
241
347
|
|
|
242
348
|
for await (const chunk of source) {
|
|
243
|
-
if (
|
|
349
|
+
if (signal.aborted) break
|
|
244
350
|
|
|
245
351
|
this.callbacksRef.onChunk?.(chunk)
|
|
352
|
+
this.observeResumeSnapshot(chunk)
|
|
246
353
|
const chunkRunId =
|
|
247
354
|
'runId' in chunk && typeof chunk.runId === 'string'
|
|
248
355
|
? chunk.runId
|
|
@@ -270,6 +377,7 @@ export class GenerationClient<
|
|
|
270
377
|
}
|
|
271
378
|
case 'RUN_FINISHED': {
|
|
272
379
|
streamRunId = chunk.runId
|
|
380
|
+
sawTerminalChunk = true
|
|
273
381
|
this.devtoolsBridge.ensureRunStarted(chunk.runId)
|
|
274
382
|
this.setStatus('success')
|
|
275
383
|
break
|
|
@@ -289,6 +397,11 @@ export class GenerationClient<
|
|
|
289
397
|
break
|
|
290
398
|
}
|
|
291
399
|
}
|
|
400
|
+
|
|
401
|
+
// An aborted read is a deliberate stop/dispose, not a truncation.
|
|
402
|
+
if (!sawTerminalChunk && !signal.aborted) {
|
|
403
|
+
throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE)
|
|
404
|
+
}
|
|
292
405
|
}
|
|
293
406
|
|
|
294
407
|
/**
|
|
@@ -307,10 +420,24 @@ export class GenerationClient<
|
|
|
307
420
|
this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
|
|
308
421
|
}
|
|
309
422
|
}
|
|
423
|
+
// A stopped run is no longer resumable. Without this the in-memory
|
|
424
|
+
// snapshot stays `running`, and a remount's `maybeResumeInFlight` would
|
|
425
|
+
// rejoin a run the user just cancelled.
|
|
426
|
+
if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') {
|
|
427
|
+
this.resumeSnapshot = {
|
|
428
|
+
...this.resumeSnapshot,
|
|
429
|
+
resumeState: null,
|
|
430
|
+
status: 'idle',
|
|
431
|
+
}
|
|
432
|
+
this.notifyResumeSnapshotChanged()
|
|
433
|
+
}
|
|
310
434
|
}
|
|
311
435
|
|
|
312
436
|
/**
|
|
313
|
-
* Clear the result, error, and return to idle state.
|
|
437
|
+
* Clear the result, error, and return to idle state. Also drops the client's
|
|
438
|
+
* in-memory resume snapshot, so a remount restores nothing. The server-side
|
|
439
|
+
* record is untouched — this client no longer writes one — so a full page
|
|
440
|
+
* reload under `persistence: true` re-hydrates the last generation again.
|
|
314
441
|
*/
|
|
315
442
|
reset(): void {
|
|
316
443
|
this.stop()
|
|
@@ -320,6 +447,7 @@ export class GenerationClient<
|
|
|
320
447
|
this.devtoolsBridge.resetRuns()
|
|
321
448
|
this.setError(undefined)
|
|
322
449
|
this.setStatus('idle')
|
|
450
|
+
this.clearResumeSnapshot()
|
|
323
451
|
this.devtoolsBridge.emitState()
|
|
324
452
|
}
|
|
325
453
|
|
|
@@ -352,9 +480,27 @@ export class GenerationClient<
|
|
|
352
480
|
}
|
|
353
481
|
|
|
354
482
|
dispose(): void {
|
|
355
|
-
this.
|
|
483
|
+
this.disposed = true
|
|
484
|
+
// Teardown, NOT a user cancel: abort in-flight DELIVERY (this reader) but
|
|
485
|
+
// do NOT call `stop()` — `stop()` marks the run non-resumable and wipes the
|
|
486
|
+
// `running` snapshot, which is correct for a Stop button but wrong for an
|
|
487
|
+
// unmount / React StrictMode dispose. Clearing it here would destroy the
|
|
488
|
+
// in-memory resume state, so a remount of this same client instance could
|
|
489
|
+
// never rejoin. (A real page revisit re-hydrates from the server instead.)
|
|
490
|
+
// The run itself survives server-side (durable delivery), so the snapshot
|
|
491
|
+
// must stay `running` for the remount to resume it.
|
|
492
|
+
if (this.abortController) {
|
|
493
|
+
this.abortController.abort()
|
|
494
|
+
this.abortController = null
|
|
495
|
+
}
|
|
496
|
+
this.setIsLoading(false)
|
|
356
497
|
this.devtoolsBridge.dispose()
|
|
357
498
|
this.devtoolsMounted = false
|
|
499
|
+
// Re-arm mount hydration + rejoin so a remount resumes from the (preserved)
|
|
500
|
+
// snapshot. `mountDevtools` re-runs the hydration entry point and
|
|
501
|
+
// `maybeResumeInFlight`, both individually guarded.
|
|
502
|
+
this.serverHydrationStarted = false
|
|
503
|
+
this.rejoinedRunId = undefined
|
|
358
504
|
}
|
|
359
505
|
|
|
360
506
|
// ===========================
|
|
@@ -377,6 +523,33 @@ export class GenerationClient<
|
|
|
377
523
|
return this.status
|
|
378
524
|
}
|
|
379
525
|
|
|
526
|
+
getResumeSnapshot(): GenerationResumeSnapshot | undefined {
|
|
527
|
+
return this.resumeSnapshot
|
|
528
|
+
? {
|
|
529
|
+
...this.resumeSnapshot,
|
|
530
|
+
...(this.resumeSnapshot.pendingArtifacts
|
|
531
|
+
? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] }
|
|
532
|
+
: {}),
|
|
533
|
+
...(this.resumeSnapshot.result
|
|
534
|
+
? {
|
|
535
|
+
result: {
|
|
536
|
+
...this.resumeSnapshot.result,
|
|
537
|
+
...(this.resumeSnapshot.result.artifacts
|
|
538
|
+
? { artifacts: [...this.resumeSnapshot.result.artifacts] }
|
|
539
|
+
: {}),
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
: {}),
|
|
543
|
+
...(this.resumeSnapshot.error
|
|
544
|
+
? { error: { ...this.resumeSnapshot.error } }
|
|
545
|
+
: {}),
|
|
546
|
+
...(this.resumeSnapshot.lastEvent
|
|
547
|
+
? { lastEvent: { ...this.resumeSnapshot.lastEvent } }
|
|
548
|
+
: {}),
|
|
549
|
+
}
|
|
550
|
+
: undefined
|
|
551
|
+
}
|
|
552
|
+
|
|
380
553
|
// ===========================
|
|
381
554
|
// Private state setters
|
|
382
555
|
// ===========================
|
|
@@ -408,7 +581,7 @@ export class GenerationClient<
|
|
|
408
581
|
// No onResult callback, or callback returned void → use raw value as
|
|
409
582
|
// TOutput. When the caller did not supply an onResult transform,
|
|
410
583
|
// `TOutput` defaults to `TResult`, so the runtime cast is sound.
|
|
411
|
-
//
|
|
584
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied
|
|
412
585
|
this.result = rawResult as unknown as TOutput
|
|
413
586
|
this.callbacksRef.onResultChange?.(this.result)
|
|
414
587
|
this.devtoolsBridge.recordResultChange()
|
|
@@ -466,6 +639,383 @@ export class GenerationClient<
|
|
|
466
639
|
runId,
|
|
467
640
|
}
|
|
468
641
|
}
|
|
642
|
+
|
|
643
|
+
private observeResumeSnapshot(chunk: StreamChunk): void {
|
|
644
|
+
this.resumeSnapshot = updateGenerationResumeSnapshot(
|
|
645
|
+
this.resumeSnapshot,
|
|
646
|
+
chunk,
|
|
647
|
+
)
|
|
648
|
+
this.notifyResumeSnapshotChanged()
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Notify the (internal) snapshot listener AND emit the public resume state.
|
|
653
|
+
* The snapshot stays internal (persistence + devtools); the hook consumes
|
|
654
|
+
* `resumeState`, mirroring the chat client.
|
|
655
|
+
*/
|
|
656
|
+
private notifyResumeSnapshotChanged(): void {
|
|
657
|
+
this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot)
|
|
658
|
+
this.emitResumeState()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Derive the public `resumeState` from the internal snapshot: the in-flight
|
|
663
|
+
* run identity, with any in-flight artifact refs folded under it. `null` once
|
|
664
|
+
* no run is in flight.
|
|
665
|
+
*
|
|
666
|
+
* The snapshot is rebuilt for every chunk, so emitting unconditionally would
|
|
667
|
+
* hand each framework hook a fresh object per chunk and re-render the
|
|
668
|
+
* component on every stream event. `resumeState` only changes at run
|
|
669
|
+
* boundaries and when artifacts land, so skip the notification unless it
|
|
670
|
+
* materially changed — same gate the persistence writes use.
|
|
671
|
+
*/
|
|
672
|
+
private emitResumeState(): void {
|
|
673
|
+
const snapshot = this.resumeSnapshot
|
|
674
|
+
const state = snapshot?.resumeState
|
|
675
|
+
const resumeState: GenerationResumeState | null = state
|
|
676
|
+
? {
|
|
677
|
+
...state,
|
|
678
|
+
...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0
|
|
679
|
+
? { pendingArtifacts: [...snapshot.pendingArtifacts] }
|
|
680
|
+
: {}),
|
|
681
|
+
}
|
|
682
|
+
: null
|
|
683
|
+
const signature = JSON.stringify(resumeState)
|
|
684
|
+
if (signature === this.lastEmittedResumeState) {
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
this.lastEmittedResumeState = signature
|
|
688
|
+
this.callbacksRef.onResumeStateChange?.(resumeState)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Repaint the normal fields from a restored snapshot (client store or server
|
|
693
|
+
* hydrate), so a reload presents the run in `result` / `status` / `error`
|
|
694
|
+
* exactly as a just-finished run would, never a bolt-on snapshot object.
|
|
695
|
+
* `isLoading` stays false: the client never auto-tails a restored run. The
|
|
696
|
+
* snapshot is not re-persisted here (it came from storage / the server).
|
|
697
|
+
*
|
|
698
|
+
* When the activity's mapper DECLINES a `complete` snapshot the repaint
|
|
699
|
+
* settles as an error instead: `success` with a `null` result is a state no
|
|
700
|
+
* consumer can render, and it hides the real cause (an output artifact
|
|
701
|
+
* persisted without a serve URL). A decline on any other status is expected —
|
|
702
|
+
* a `running` snapshot has no result yet, the rejoin will deliver it.
|
|
703
|
+
*/
|
|
704
|
+
private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void {
|
|
705
|
+
this.resumeSnapshot = snapshot
|
|
706
|
+
this.notifyResumeSnapshotChanged()
|
|
707
|
+
this.setStatus(clientStateFromResumeStatus(snapshot.status))
|
|
708
|
+
this.setError(
|
|
709
|
+
snapshot.error
|
|
710
|
+
? Object.assign(
|
|
711
|
+
new Error(snapshot.error.message),
|
|
712
|
+
snapshot.error.code ? { code: snapshot.error.code } : {},
|
|
713
|
+
)
|
|
714
|
+
: undefined,
|
|
715
|
+
)
|
|
716
|
+
const restored = this.reconstructRestoredResult(snapshot)
|
|
717
|
+
if (restored !== null) {
|
|
718
|
+
this.setResult(restored)
|
|
719
|
+
} else if (
|
|
720
|
+
this.callbacksRef.reconstructResult &&
|
|
721
|
+
snapshot.status === 'complete'
|
|
722
|
+
) {
|
|
723
|
+
this.reportUnrestorableResult()
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Report a `complete` snapshot the activity's mapper could not rebuild.
|
|
729
|
+
* Runs after the status/error repaint above, so it wins over the snapshot's
|
|
730
|
+
* own `complete` status.
|
|
731
|
+
*/
|
|
732
|
+
private reportUnrestorableResult(): void {
|
|
733
|
+
const error = new Error(GENERATION_UNRESTORABLE_RESULT_MESSAGE)
|
|
734
|
+
this.setStatus('error')
|
|
735
|
+
this.setError(error)
|
|
736
|
+
this.callbacksRef.onError?.(error)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Repaint a restored snapshot (client store or server hydrate) and, when it
|
|
741
|
+
* reports a run still in flight, tail that run to completion via `joinRun`
|
|
742
|
+
* (from the connection, or the `joinRun` option when the transport can't
|
|
743
|
+
* carry one).
|
|
744
|
+
*
|
|
745
|
+
* A `running` snapshot that no `joinRun` handler can tail is repainted as an
|
|
746
|
+
* interrupted error instead of a `generating` status that would never
|
|
747
|
+
* settle: an interrupted generation cannot be resumed, only re-run.
|
|
748
|
+
*/
|
|
749
|
+
private repaintRestoredSnapshot(
|
|
750
|
+
snapshot: GenerationResumeSnapshot,
|
|
751
|
+
activeRunId?: string,
|
|
752
|
+
): void {
|
|
753
|
+
if (snapshot.status !== 'running') {
|
|
754
|
+
this.repaintFromSnapshot(snapshot)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
const joinRun = this.connection?.joinRun ?? this.joinRunHandler
|
|
758
|
+
const runId = activeRunId ?? snapshot.resumeState?.runId
|
|
759
|
+
if (runId && joinRun) {
|
|
760
|
+
this.repaintFromSnapshot(snapshot)
|
|
761
|
+
this.rejoinInFlight(runId)
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
this.repaintFromSnapshot({
|
|
765
|
+
...snapshot,
|
|
766
|
+
resumeState: null,
|
|
767
|
+
status: 'error',
|
|
768
|
+
error: {
|
|
769
|
+
message:
|
|
770
|
+
'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.',
|
|
771
|
+
},
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Build the restorable result shape from the snapshot and hand it to the
|
|
777
|
+
* per-activity `reconstructResult` mapper (injected by the specialized
|
|
778
|
+
* client/hook, which knows the concrete result type).
|
|
779
|
+
*
|
|
780
|
+
* Returns `null` both when no mapper is set (nothing to rebuild — `result`
|
|
781
|
+
* simply stays null) and when the mapper declines. The caller distinguishes
|
|
782
|
+
* the two: see {@link repaintFromSnapshot}.
|
|
783
|
+
*/
|
|
784
|
+
private reconstructRestoredResult(
|
|
785
|
+
snapshot: GenerationResumeSnapshot,
|
|
786
|
+
): TResult | null {
|
|
787
|
+
const build = this.callbacksRef.reconstructResult
|
|
788
|
+
if (!build) return null
|
|
789
|
+
const result = snapshot.result
|
|
790
|
+
const restored: GenerationRestoredResult = {
|
|
791
|
+
...(result?.id !== undefined ? { id: result.id } : {}),
|
|
792
|
+
...(result?.model !== undefined ? { model: result.model } : {}),
|
|
793
|
+
...(result?.status !== undefined ? { status: result.status } : {}),
|
|
794
|
+
...(result?.providerJobId !== undefined
|
|
795
|
+
? { providerJobId: result.providerJobId }
|
|
796
|
+
: {}),
|
|
797
|
+
...(result?.expiresAt !== undefined
|
|
798
|
+
? { expiresAt: result.expiresAt }
|
|
799
|
+
: {}),
|
|
800
|
+
...(result?.text !== undefined ? { text: result.text } : {}),
|
|
801
|
+
...(result?.usage !== undefined ? { usage: result.usage } : {}),
|
|
802
|
+
...(snapshot.activity !== undefined
|
|
803
|
+
? { activity: snapshot.activity }
|
|
804
|
+
: {}),
|
|
805
|
+
artifacts: result?.artifacts ?? [],
|
|
806
|
+
}
|
|
807
|
+
return build(restored)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The plain (non-Response) fetcher path never observes stream chunks, so
|
|
812
|
+
* the terminal snapshot is built here from the fetcher's own result. A
|
|
813
|
+
* stale `error` from a previous run is intentionally dropped — this run
|
|
814
|
+
* succeeded.
|
|
815
|
+
*/
|
|
816
|
+
private completePlainFetcherResumeSnapshot(rawResult: unknown): void {
|
|
817
|
+
const previous = this.resumeSnapshot
|
|
818
|
+
const result = createGenerationResultSnapshot(rawResult)
|
|
819
|
+
this.resumeSnapshot = {
|
|
820
|
+
schemaVersion: 1,
|
|
821
|
+
resumeState: null,
|
|
822
|
+
status: 'complete',
|
|
823
|
+
...(previous?.activity ? { activity: previous.activity } : {}),
|
|
824
|
+
...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
|
|
825
|
+
? { pendingArtifacts: [...previous.pendingArtifacts] }
|
|
826
|
+
: {}),
|
|
827
|
+
...(result
|
|
828
|
+
? { result }
|
|
829
|
+
: previous?.result
|
|
830
|
+
? { result: { ...previous.result } }
|
|
831
|
+
: {}),
|
|
832
|
+
}
|
|
833
|
+
this.notifyResumeSnapshotChanged()
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Records a transport-level failure (network drop, throwing callback) in
|
|
838
|
+
* the snapshot. Without this, only a server-emitted RUN_ERROR chunk would
|
|
839
|
+
* mark the snapshot `error`, leaving a persisted record that claims the
|
|
840
|
+
* run is still in flight.
|
|
841
|
+
*/
|
|
842
|
+
private recordResumeSnapshotError(error: Error): void {
|
|
843
|
+
// Surface the failure on the OBSERVABLE fields FIRST: a rejoin (or live
|
|
844
|
+
// stream) that emits RUN_ERROR has already flipped the snapshot to `error`
|
|
845
|
+
// via `observeResumeSnapshot`, so the early-return below would otherwise
|
|
846
|
+
// skip this and leave `status` stuck on `generating` — the run would look
|
|
847
|
+
// like it is still going forever. The guard avoids a duplicate `error`
|
|
848
|
+
// emission on the live `generate()` path, which sets the status itself.
|
|
849
|
+
if (this.status !== 'error') this.setStatus('error')
|
|
850
|
+
this.setError(error)
|
|
851
|
+
if (this.resumeSnapshot?.status === 'error') return
|
|
852
|
+
if (!this.resumeSnapshot && !this.serverDriven) return
|
|
853
|
+
const previous = this.resumeSnapshot
|
|
854
|
+
this.resumeSnapshot = {
|
|
855
|
+
schemaVersion: 1,
|
|
856
|
+
resumeState: null,
|
|
857
|
+
status: 'error',
|
|
858
|
+
...(previous?.activity ? { activity: previous.activity } : {}),
|
|
859
|
+
...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
|
|
860
|
+
? { pendingArtifacts: [...previous.pendingArtifacts] }
|
|
861
|
+
: {}),
|
|
862
|
+
...(previous?.result ? { result: { ...previous.result } } : {}),
|
|
863
|
+
error: { message: error.message },
|
|
864
|
+
}
|
|
865
|
+
this.notifyResumeSnapshotChanged()
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Drop the client's in-memory snapshot and re-emit. Purely local — this
|
|
870
|
+
* client writes no storage, so nothing persisted is removed.
|
|
871
|
+
*/
|
|
872
|
+
private clearResumeSnapshot(): void {
|
|
873
|
+
this.resumeSnapshot = undefined
|
|
874
|
+
this.lastEmittedResumeState = undefined
|
|
875
|
+
this.notifyResumeSnapshotChanged()
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Server-driven mount hydration entry point (`persistence: true`). Runs at
|
|
880
|
+
* most once, from the commit-phase mount path (`mountDevtools`) — never the
|
|
881
|
+
* constructor / render phase — so remounts and speculative renders can't
|
|
882
|
+
* re-fire the hydrate GET.
|
|
883
|
+
*/
|
|
884
|
+
private maybeHydrateFromServer(): void {
|
|
885
|
+
if (!this.serverDriven || this.serverHydrationStarted) return
|
|
886
|
+
this.serverHydrationStarted = true
|
|
887
|
+
if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) {
|
|
888
|
+
this.hydrateFromServer()
|
|
889
|
+
} else {
|
|
890
|
+
// `persistence: true` without any hydrate source can never restore
|
|
891
|
+
// anything — warn rather than silently no-op.
|
|
892
|
+
console.warn(
|
|
893
|
+
'[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.',
|
|
894
|
+
)
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Server-driven mount hydration (`persistence: true`). The client holds no
|
|
900
|
+
* local snapshot; on mount it asks the server — keyed by the stable threadId —
|
|
901
|
+
* for the last generation's resume snapshot, validates it, and repaints it. It
|
|
902
|
+
* never auto-starts a run, and never blocks: a `generate()` that starts first
|
|
903
|
+
* owns the client and hydration backs off, mirroring the chat client.
|
|
904
|
+
*
|
|
905
|
+
* A genuine **miss** (the server reports no record for the thread) is silent —
|
|
906
|
+
* a fresh thread is not an error. A genuine **failure** (transport error, a
|
|
907
|
+
* 403 from the authorize gate, a malformed body, a record the client's own
|
|
908
|
+
* validator rejects) is surfaced through `status` / `error` / `onError`, so a
|
|
909
|
+
* broken server is distinguishable from an empty one and the app can retry.
|
|
910
|
+
*/
|
|
911
|
+
private hydrateFromServer(): void {
|
|
912
|
+
const hydrate =
|
|
913
|
+
this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler
|
|
914
|
+
if (!hydrate) return
|
|
915
|
+
// A send that already started owns the client; don't stomp it.
|
|
916
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
|
|
917
|
+
void (async () => {
|
|
918
|
+
let res: GenerationHydrationResult
|
|
919
|
+
try {
|
|
920
|
+
res = await hydrate(this.threadId)
|
|
921
|
+
} catch (cause) {
|
|
922
|
+
this.failHydration(
|
|
923
|
+
createGenerationHydrationError(
|
|
924
|
+
'the request to the server did not succeed',
|
|
925
|
+
cause,
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
// No record for this thread — a fresh thread, not a failure.
|
|
931
|
+
if (!res.resumeSnapshot) return
|
|
932
|
+
const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot)
|
|
933
|
+
if (!snapshot) {
|
|
934
|
+
this.failHydration(
|
|
935
|
+
createGenerationHydrationError(
|
|
936
|
+
'the server returned a record this client cannot read (unknown schema version, or a missing/invalid `status` or `resumeState`)',
|
|
937
|
+
),
|
|
938
|
+
)
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
// Re-check: a send may have started while the fetch was in flight.
|
|
942
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle')
|
|
943
|
+
return
|
|
944
|
+
// A run still generating on the server: re-attach and finish it in place.
|
|
945
|
+
this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId)
|
|
946
|
+
})()
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Surface a hydration failure on the observable fields. Skipped when a
|
|
951
|
+
* `generate()` took ownership while the hydrate GET was in flight — the live
|
|
952
|
+
* run's state must win over a stale mount-time failure.
|
|
953
|
+
*/
|
|
954
|
+
private failHydration(error: Error): void {
|
|
955
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
|
|
956
|
+
this.setStatus('error')
|
|
957
|
+
this.setError(error)
|
|
958
|
+
this.callbacksRef.onError?.(error)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Re-attach to an already-loaded `running` snapshot (the remount case). Safe
|
|
963
|
+
* to call repeatedly: `rejoinInFlight` dedupes on `rejoinedRunId` and bails
|
|
964
|
+
* when a run is already in flight, so on the first mount (where the rejoin was
|
|
965
|
+
* already started from `repaintRestoredSnapshot`) this is a no-op.
|
|
966
|
+
*/
|
|
967
|
+
private maybeResumeInFlight(): void {
|
|
968
|
+
if (this.resumeSnapshot?.status !== 'running') return
|
|
969
|
+
const runId = this.resumeSnapshot.resumeState?.runId
|
|
970
|
+
if (runId) this.rejoinInFlight(runId)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Re-attach to a run that is still generating and stream it to completion,
|
|
975
|
+
* mirroring the chat client's mount-time rejoin. Reuses `processStream`, so
|
|
976
|
+
* `result` / `progress` / `status` repaint from the replayed chunks exactly as
|
|
977
|
+
* a live run does. Best-effort: a live `generate()` owns the client and is
|
|
978
|
+
* never stomped, and the same run is only rejoined once.
|
|
979
|
+
*/
|
|
980
|
+
private rejoinInFlight(runId: string): void {
|
|
981
|
+
const joinRun = this.connection?.joinRun ?? this.joinRunHandler
|
|
982
|
+
if (!joinRun) return
|
|
983
|
+
if (this.rejoinedRunId === runId) return
|
|
984
|
+
// A fresh send (or an in-progress rejoin) owns the client.
|
|
985
|
+
if (this.isLoading || this.abortController) return
|
|
986
|
+
this.rejoinedRunId = runId
|
|
987
|
+
const controller = new AbortController()
|
|
988
|
+
this.abortController = controller
|
|
989
|
+
this.setIsLoading(true)
|
|
990
|
+
this.setStatus('generating')
|
|
991
|
+
void (async () => {
|
|
992
|
+
try {
|
|
993
|
+
await this.processStream(
|
|
994
|
+
joinRun(runId, controller.signal),
|
|
995
|
+
runId,
|
|
996
|
+
controller.signal,
|
|
997
|
+
)
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if (!controller.signal.aborted) {
|
|
1000
|
+
const failure =
|
|
1001
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1002
|
+
// Settles `status`/`error` AND rewrites the snapshot to a terminal
|
|
1003
|
+
// `error` with a null `resumeState`, so the next mount does not
|
|
1004
|
+
// rejoin this run again.
|
|
1005
|
+
this.recordResumeSnapshotError(failure)
|
|
1006
|
+
this.callbacksRef.onError?.(failure)
|
|
1007
|
+
}
|
|
1008
|
+
} finally {
|
|
1009
|
+
// Only reset if this rejoin still owns the client: a `stop()` +
|
|
1010
|
+
// fresh `generate()` may have replaced the controller while the tail
|
|
1011
|
+
// was settling, and that live run owns `isLoading` now.
|
|
1012
|
+
if (this.abortController === controller) {
|
|
1013
|
+
this.abortController = null
|
|
1014
|
+
this.setIsLoading(false)
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
})()
|
|
1018
|
+
}
|
|
469
1019
|
}
|
|
470
1020
|
|
|
471
1021
|
function completeProgressValue(
|