@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
|
@@ -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 { createNoOpVideoDevtoolsBridge } 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 {
|
|
@@ -15,6 +25,8 @@ import type {
|
|
|
15
25
|
import type {
|
|
16
26
|
GenerationClientState,
|
|
17
27
|
GenerationFetcher,
|
|
28
|
+
GenerationResumeSnapshot,
|
|
29
|
+
GenerationResumeState,
|
|
18
30
|
VideoGenerateInput,
|
|
19
31
|
VideoGenerateResult,
|
|
20
32
|
VideoGenerationClientOptions,
|
|
@@ -42,6 +54,12 @@ interface VideoCallbacks<TOutput> {
|
|
|
42
54
|
onStatusChange?: ((status: GenerationClientState) => void) | undefined
|
|
43
55
|
onJobIdChange?: ((jobId: string | null) => void) | undefined
|
|
44
56
|
onVideoStatusChange?: ((status: VideoStatusInfo | null) => void) | undefined
|
|
57
|
+
onResumeSnapshotChange?:
|
|
58
|
+
| ((snapshot: GenerationResumeSnapshot | undefined) => void)
|
|
59
|
+
| undefined
|
|
60
|
+
onResumeStateChange?:
|
|
61
|
+
| ((resumeState: GenerationResumeState | null) => void)
|
|
62
|
+
| undefined
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/**
|
|
@@ -81,6 +99,15 @@ interface VideoCallbacks<TOutput> {
|
|
|
81
99
|
*/
|
|
82
100
|
export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
83
101
|
private readonly connection: ConnectConnectionAdapter | 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
|
|
84
111
|
private readonly fetcher:
|
|
85
112
|
| GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
|
|
86
113
|
| undefined
|
|
@@ -88,6 +115,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
88
115
|
private readonly devtoolsMetadata: AIDevtoolsClientMetadata
|
|
89
116
|
private readonly devtoolsBridge: VideoDevtoolsBridge<TOutput>
|
|
90
117
|
private readonly threadId: string
|
|
118
|
+
// Server-driven mode (`persistence: true`): no local snapshot store; on mount
|
|
119
|
+
// the client hydrates the last generation for `threadId` from the server.
|
|
120
|
+
private readonly serverDriven: boolean = false
|
|
91
121
|
private body: Record<string, any>
|
|
92
122
|
|
|
93
123
|
private result: TOutput | null = null
|
|
@@ -98,9 +128,13 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
98
128
|
private isLoading = false
|
|
99
129
|
private error: Error | undefined = undefined
|
|
100
130
|
private status: GenerationClientState = 'idle'
|
|
131
|
+
private resumeSnapshot: GenerationResumeSnapshot | undefined
|
|
101
132
|
private abortController: AbortController | null = null
|
|
133
|
+
private rejoinedRunId: string | undefined
|
|
102
134
|
private readonly callbacksRef: VideoCallbacks<TOutput>
|
|
103
135
|
private devtoolsMounted = false
|
|
136
|
+
private disposed = false
|
|
137
|
+
private serverHydrationStarted = false
|
|
104
138
|
|
|
105
139
|
constructor(
|
|
106
140
|
options: VideoGenerationClientOptions<TOutput> &
|
|
@@ -112,12 +146,21 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
112
146
|
}
|
|
113
147
|
),
|
|
114
148
|
) {
|
|
115
|
-
|
|
116
|
-
|
|
149
|
+
// `threadId` is the single identity. Deprecated `id` is only a fallback
|
|
150
|
+
// when no threadId is given (ephemeral runs / legacy call sites).
|
|
151
|
+
this.uniqueId =
|
|
152
|
+
options.threadId ?? options.id ?? this.generateUniqueId('video')
|
|
153
|
+
// The wire/hydration thread key. Server-driven mode needs a stable key, so
|
|
154
|
+
// prefer an explicit `threadId`, then legacy `id`, then a generated id.
|
|
155
|
+
this.threadId = options.threadId ?? this.uniqueId
|
|
117
156
|
this.connection = options.connection
|
|
118
157
|
this.fetcher = options.fetcher
|
|
158
|
+
this.hydrateGenerationHandler = options.hydrateGeneration
|
|
159
|
+
this.joinRunHandler = options.joinRun
|
|
119
160
|
this.body = options.body ?? {}
|
|
120
|
-
|
|
161
|
+
// `persistence` is `false`/omitted (ephemeral) or `true` (server-driven:
|
|
162
|
+
// hydrate the last generation for `threadId` from the server on mount).
|
|
163
|
+
this.serverDriven = options.persistence === true
|
|
121
164
|
this.callbacksRef = {
|
|
122
165
|
onResult: options.onResult,
|
|
123
166
|
onError: options.onError,
|
|
@@ -131,12 +174,21 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
131
174
|
onStatusChange: options.onStatusChange,
|
|
132
175
|
onJobIdChange: options.onJobIdChange,
|
|
133
176
|
onVideoStatusChange: options.onVideoStatusChange,
|
|
177
|
+
onResumeSnapshotChange: options.onResumeSnapshotChange,
|
|
178
|
+
onResumeStateChange: options.onResumeStateChange,
|
|
134
179
|
}
|
|
135
180
|
|
|
136
181
|
this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
|
|
137
182
|
this.devtoolsBridge = (
|
|
138
183
|
options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge
|
|
139
184
|
)<TOutput>(this.buildDevtoolsBridgeOptions())
|
|
185
|
+
|
|
186
|
+
// Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework
|
|
187
|
+
// hooks build this client inside `useMemo`, so the constructor executes in
|
|
188
|
+
// React's render phase; hydrating here would re-fire the hydrate GET on
|
|
189
|
+
// every discarded/speculative render, flooding the connection pool when
|
|
190
|
+
// several clients mount together. It is kicked off once from
|
|
191
|
+
// `mountDevtools`, which the hooks call from a commit-phase mount effect.
|
|
140
192
|
}
|
|
141
193
|
|
|
142
194
|
private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions<TOutput> {
|
|
@@ -159,6 +211,15 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
159
211
|
}
|
|
160
212
|
|
|
161
213
|
mountDevtools(): void {
|
|
214
|
+
// Mounting revives a disposed client. Framework hooks call this from
|
|
215
|
+
// their mount effect, so a dispose → remount cycle (e.g. React
|
|
216
|
+
// StrictMode's mount → cleanup → mount replay against the same memoized
|
|
217
|
+
// client) leaves the client usable again.
|
|
218
|
+
this.disposed = false
|
|
219
|
+
this.maybeHydrateFromServer()
|
|
220
|
+
// Re-attach to an already-loaded `running` snapshot (remount case); see the
|
|
221
|
+
// note in GenerationClient.mountDevtools. Guarded by rejoinInFlight.
|
|
222
|
+
this.maybeResumeInFlight()
|
|
162
223
|
if (this.devtoolsMounted) {
|
|
163
224
|
return
|
|
164
225
|
}
|
|
@@ -173,8 +234,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
173
234
|
* Only one generation can be in-flight at a time.
|
|
174
235
|
*/
|
|
175
236
|
async generate(input: VideoGenerateInput): Promise<void> {
|
|
176
|
-
this.
|
|
237
|
+
if (this.disposed) return
|
|
177
238
|
if (this.isLoading) return
|
|
239
|
+
this.mountDevtools()
|
|
178
240
|
|
|
179
241
|
this.input = input
|
|
180
242
|
this.progress = null
|
|
@@ -200,7 +262,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
200
262
|
signal,
|
|
201
263
|
this.createRunContext(runId),
|
|
202
264
|
)
|
|
203
|
-
await this.processStream(stream, runId)
|
|
265
|
+
await this.processStream(stream, runId, signal)
|
|
204
266
|
} else {
|
|
205
267
|
throw new Error(
|
|
206
268
|
'VideoGenerationClient requires either a connection or fetcher option',
|
|
@@ -218,6 +280,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
218
280
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
219
281
|
this.setError(error)
|
|
220
282
|
this.setStatus('error')
|
|
283
|
+
this.recordResumeSnapshotError(error)
|
|
221
284
|
this.devtoolsBridge.finishRun(
|
|
222
285
|
this.devtoolsBridge.getActiveRunId() ?? runId,
|
|
223
286
|
'run:errored',
|
|
@@ -226,8 +289,10 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
226
289
|
)
|
|
227
290
|
this.callbacksRef.onError?.(error)
|
|
228
291
|
} finally {
|
|
229
|
-
this.abortController
|
|
230
|
-
|
|
292
|
+
if (this.abortController === abortController) {
|
|
293
|
+
this.abortController = null
|
|
294
|
+
this.setIsLoading(false)
|
|
295
|
+
}
|
|
231
296
|
}
|
|
232
297
|
}
|
|
233
298
|
|
|
@@ -247,28 +312,37 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
247
312
|
|
|
248
313
|
if (result instanceof Response) {
|
|
249
314
|
// Server function returned SSE Response — parse stream
|
|
250
|
-
await this.processStream(parseSSEResponse(result, signal), runId)
|
|
315
|
+
await this.processStream(parseSSEResponse(result, signal), runId, signal)
|
|
251
316
|
} else {
|
|
252
317
|
this.devtoolsBridge.ensureRunStarted(runId)
|
|
253
318
|
this.setResult(result)
|
|
254
319
|
this.setStatus('success')
|
|
320
|
+
this.completePlainFetcherResumeSnapshot(result)
|
|
255
321
|
}
|
|
256
322
|
}
|
|
257
323
|
|
|
258
324
|
/**
|
|
259
325
|
* Process a stream of AG-UI events from the streaming connection adapter.
|
|
260
326
|
* The server handles the polling loop and streams status updates.
|
|
327
|
+
*
|
|
328
|
+
* Throws {@link GENERATION_STREAM_TRUNCATED_MESSAGE} when the iteration ends
|
|
329
|
+
* without a terminal chunk — see the note on
|
|
330
|
+
* `GenerationClient.processStream`. Video runs are long enough that a proxy
|
|
331
|
+
* idle timeout mid-poll is the likeliest way to hit it.
|
|
261
332
|
*/
|
|
262
333
|
private async processStream(
|
|
263
334
|
source: AsyncIterable<StreamChunk>,
|
|
264
335
|
fallbackRunId: string,
|
|
336
|
+
signal: AbortSignal,
|
|
265
337
|
): Promise<void> {
|
|
266
338
|
let streamRunId: string | undefined
|
|
339
|
+
let sawTerminalChunk = false
|
|
267
340
|
|
|
268
341
|
for await (const chunk of source) {
|
|
269
|
-
if (
|
|
342
|
+
if (signal.aborted) break
|
|
270
343
|
|
|
271
344
|
this.callbacksRef.onChunk?.(chunk)
|
|
345
|
+
this.observeResumeSnapshot(chunk)
|
|
272
346
|
const chunkRunId =
|
|
273
347
|
'runId' in chunk && typeof chunk.runId === 'string'
|
|
274
348
|
? chunk.runId
|
|
@@ -307,6 +381,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
307
381
|
}
|
|
308
382
|
case 'RUN_FINISHED': {
|
|
309
383
|
streamRunId = chunk.runId
|
|
384
|
+
sawTerminalChunk = true
|
|
310
385
|
this.devtoolsBridge.ensureRunStarted(chunk.runId)
|
|
311
386
|
this.setStatus('success')
|
|
312
387
|
break
|
|
@@ -326,6 +401,11 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
326
401
|
break
|
|
327
402
|
}
|
|
328
403
|
}
|
|
404
|
+
|
|
405
|
+
// An aborted read is a deliberate stop/dispose, not a truncation.
|
|
406
|
+
if (!sawTerminalChunk && !signal.aborted) {
|
|
407
|
+
throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE)
|
|
408
|
+
}
|
|
329
409
|
}
|
|
330
410
|
|
|
331
411
|
/**
|
|
@@ -344,10 +424,24 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
344
424
|
this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
|
|
345
425
|
}
|
|
346
426
|
}
|
|
427
|
+
// A stopped run is no longer resumable. Without this the in-memory
|
|
428
|
+
// snapshot stays `running`, and a remount's `maybeResumeInFlight` would
|
|
429
|
+
// rejoin a run the user just cancelled.
|
|
430
|
+
if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') {
|
|
431
|
+
this.resumeSnapshot = {
|
|
432
|
+
...this.resumeSnapshot,
|
|
433
|
+
resumeState: null,
|
|
434
|
+
status: 'idle',
|
|
435
|
+
}
|
|
436
|
+
this.notifyResumeSnapshotChanged()
|
|
437
|
+
}
|
|
347
438
|
}
|
|
348
439
|
|
|
349
440
|
/**
|
|
350
|
-
* Clear all state and return to idle.
|
|
441
|
+
* Clear all state and return to idle. Also drops the client's in-memory
|
|
442
|
+
* resume snapshot, so a remount restores nothing. The server-side record is
|
|
443
|
+
* untouched — this client no longer writes one — so a full page reload under
|
|
444
|
+
* `persistence: true` re-hydrates the last generation again.
|
|
351
445
|
*/
|
|
352
446
|
reset(): void {
|
|
353
447
|
this.stop()
|
|
@@ -359,6 +453,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
359
453
|
this.setVideoStatus(null)
|
|
360
454
|
this.setError(undefined)
|
|
361
455
|
this.setStatus('idle')
|
|
456
|
+
this.clearResumeSnapshot()
|
|
362
457
|
this.devtoolsBridge.emitState()
|
|
363
458
|
}
|
|
364
459
|
|
|
@@ -403,9 +498,18 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
403
498
|
}
|
|
404
499
|
|
|
405
500
|
dispose(): void {
|
|
406
|
-
this.
|
|
501
|
+
this.disposed = true
|
|
502
|
+
// Teardown, NOT a user cancel (see GenerationClient.dispose): abort in-flight
|
|
503
|
+
// delivery but keep the `running` snapshot resumable so a remount rejoins.
|
|
504
|
+
if (this.abortController) {
|
|
505
|
+
this.abortController.abort()
|
|
506
|
+
this.abortController = null
|
|
507
|
+
}
|
|
508
|
+
this.setIsLoading(false)
|
|
407
509
|
this.devtoolsBridge.dispose()
|
|
408
510
|
this.devtoolsMounted = false
|
|
511
|
+
this.serverHydrationStarted = false
|
|
512
|
+
this.rejoinedRunId = undefined
|
|
409
513
|
}
|
|
410
514
|
|
|
411
515
|
// ===========================
|
|
@@ -436,6 +540,33 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
436
540
|
return this.status
|
|
437
541
|
}
|
|
438
542
|
|
|
543
|
+
getResumeSnapshot(): GenerationResumeSnapshot | undefined {
|
|
544
|
+
return this.resumeSnapshot
|
|
545
|
+
? {
|
|
546
|
+
...this.resumeSnapshot,
|
|
547
|
+
...(this.resumeSnapshot.pendingArtifacts
|
|
548
|
+
? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] }
|
|
549
|
+
: {}),
|
|
550
|
+
...(this.resumeSnapshot.result
|
|
551
|
+
? {
|
|
552
|
+
result: {
|
|
553
|
+
...this.resumeSnapshot.result,
|
|
554
|
+
...(this.resumeSnapshot.result.artifacts
|
|
555
|
+
? { artifacts: [...this.resumeSnapshot.result.artifacts] }
|
|
556
|
+
: {}),
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
560
|
+
...(this.resumeSnapshot.error
|
|
561
|
+
? { error: { ...this.resumeSnapshot.error } }
|
|
562
|
+
: {}),
|
|
563
|
+
...(this.resumeSnapshot.lastEvent
|
|
564
|
+
? { lastEvent: { ...this.resumeSnapshot.lastEvent } }
|
|
565
|
+
: {}),
|
|
566
|
+
}
|
|
567
|
+
: undefined
|
|
568
|
+
}
|
|
569
|
+
|
|
439
570
|
// ===========================
|
|
440
571
|
// Private state setters
|
|
441
572
|
// ===========================
|
|
@@ -475,7 +606,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
475
606
|
// TOutput. When the caller did not supply an onResult transform,
|
|
476
607
|
// `TOutput` defaults to `VideoGenerateResult`, so the runtime cast is
|
|
477
608
|
// sound.
|
|
478
|
-
//
|
|
609
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied
|
|
479
610
|
this.result = rawResult as unknown as TOutput
|
|
480
611
|
this.callbacksRef.onResultChange?.(this.result)
|
|
481
612
|
this.devtoolsBridge.recordResultChange()
|
|
@@ -556,4 +687,339 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
556
687
|
runId,
|
|
557
688
|
}
|
|
558
689
|
}
|
|
690
|
+
|
|
691
|
+
private observeResumeSnapshot(chunk: StreamChunk): void {
|
|
692
|
+
this.resumeSnapshot = updateGenerationResumeSnapshot(
|
|
693
|
+
this.resumeSnapshot,
|
|
694
|
+
chunk,
|
|
695
|
+
)
|
|
696
|
+
this.notifyResumeSnapshotChanged()
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Notify the internal snapshot listener AND emit the public resume state. */
|
|
700
|
+
private notifyResumeSnapshotChanged(): void {
|
|
701
|
+
this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot)
|
|
702
|
+
this.emitResumeState()
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Derive the public `resumeState` from the internal snapshot. */
|
|
706
|
+
private emitResumeState(): void {
|
|
707
|
+
const snapshot = this.resumeSnapshot
|
|
708
|
+
const state = snapshot?.resumeState
|
|
709
|
+
const resumeState: GenerationResumeState | null = state
|
|
710
|
+
? {
|
|
711
|
+
...state,
|
|
712
|
+
...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0
|
|
713
|
+
? { pendingArtifacts: [...snapshot.pendingArtifacts] }
|
|
714
|
+
: {}),
|
|
715
|
+
}
|
|
716
|
+
: null
|
|
717
|
+
this.callbacksRef.onResumeStateChange?.(resumeState)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Repaint the normal fields from a restored snapshot so a reload presents the
|
|
722
|
+
* video in `result` / `status` / `error` / `jobId`, never a snapshot object.
|
|
723
|
+
* `isLoading` stays false (no auto-tail). Not re-persisted (it came from
|
|
724
|
+
* storage / the server).
|
|
725
|
+
*
|
|
726
|
+
* A `complete` snapshot with no durable video artifact cannot be rebuilt, so
|
|
727
|
+
* it repaints as an error rather than a `success` with a `null` result — see
|
|
728
|
+
* the note on `GenerationClient.repaintFromSnapshot`.
|
|
729
|
+
*/
|
|
730
|
+
private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void {
|
|
731
|
+
this.resumeSnapshot = snapshot
|
|
732
|
+
this.notifyResumeSnapshotChanged()
|
|
733
|
+
this.setStatus(clientStateFromResumeStatus(snapshot.status))
|
|
734
|
+
this.setError(
|
|
735
|
+
snapshot.error
|
|
736
|
+
? Object.assign(
|
|
737
|
+
new Error(snapshot.error.message),
|
|
738
|
+
snapshot.error.code ? { code: snapshot.error.code } : {},
|
|
739
|
+
)
|
|
740
|
+
: undefined,
|
|
741
|
+
)
|
|
742
|
+
if (snapshot.result?.providerJobId)
|
|
743
|
+
this.setJobId(snapshot.result.providerJobId)
|
|
744
|
+
const restored = this.reconstructVideoResult(snapshot)
|
|
745
|
+
if (restored !== null) {
|
|
746
|
+
this.setResult(restored)
|
|
747
|
+
} else if (snapshot.status === 'complete') {
|
|
748
|
+
this.reportUnrestorableResult()
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Report a `complete` snapshot with no durable video artifact to rebuild
|
|
754
|
+
* from. Runs after the status/error repaint above, so it wins over the
|
|
755
|
+
* snapshot's own `complete` status.
|
|
756
|
+
*/
|
|
757
|
+
private reportUnrestorableResult(): void {
|
|
758
|
+
const error = new Error(GENERATION_UNRESTORABLE_RESULT_MESSAGE)
|
|
759
|
+
this.setStatus('error')
|
|
760
|
+
this.setError(error)
|
|
761
|
+
this.callbacksRef.onError?.(error)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Repaint a restored snapshot (client store or server hydrate) and, when it
|
|
766
|
+
* reports a run still in flight, tail that run to completion via `joinRun`
|
|
767
|
+
* (from the connection, or the `joinRun` option when the transport can't
|
|
768
|
+
* carry one).
|
|
769
|
+
*
|
|
770
|
+
* A `running` snapshot that no `joinRun` handler can tail is repainted as an
|
|
771
|
+
* interrupted error instead of a `generating` status that would never
|
|
772
|
+
* settle: an interrupted generation cannot be resumed, only re-run.
|
|
773
|
+
*/
|
|
774
|
+
private repaintRestoredSnapshot(
|
|
775
|
+
snapshot: GenerationResumeSnapshot,
|
|
776
|
+
activeRunId?: string,
|
|
777
|
+
): void {
|
|
778
|
+
if (snapshot.status !== 'running') {
|
|
779
|
+
this.repaintFromSnapshot(snapshot)
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
const joinRun = this.connection?.joinRun ?? this.joinRunHandler
|
|
783
|
+
const runId = activeRunId ?? snapshot.resumeState?.runId
|
|
784
|
+
if (runId && joinRun) {
|
|
785
|
+
this.repaintFromSnapshot(snapshot)
|
|
786
|
+
this.rejoinInFlight(runId)
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
this.repaintFromSnapshot({
|
|
790
|
+
...snapshot,
|
|
791
|
+
resumeState: null,
|
|
792
|
+
status: 'error',
|
|
793
|
+
error: {
|
|
794
|
+
message:
|
|
795
|
+
'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.',
|
|
796
|
+
},
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Rebuild a `VideoGenerateResult` from a restored snapshot: the video's bytes
|
|
802
|
+
* are served from the durable artifact URL, so the restored result renders
|
|
803
|
+
* from your own origin. Returns `null` when there is no durable video artifact.
|
|
804
|
+
*/
|
|
805
|
+
private reconstructVideoResult(
|
|
806
|
+
snapshot: GenerationResumeSnapshot,
|
|
807
|
+
): VideoGenerateResult | null {
|
|
808
|
+
const result = snapshot.result
|
|
809
|
+
const artifacts = result?.artifacts ?? []
|
|
810
|
+
const output = artifacts.find(
|
|
811
|
+
(a) =>
|
|
812
|
+
a.role === 'output' && a.source.mediaType === 'video' && a.url != null,
|
|
813
|
+
)
|
|
814
|
+
if (!output?.url) return null
|
|
815
|
+
return {
|
|
816
|
+
jobId: result?.providerJobId ?? '',
|
|
817
|
+
status: 'completed',
|
|
818
|
+
url: output.url,
|
|
819
|
+
...(result?.expiresAt ? { expiresAt: new Date(result.expiresAt) } : {}),
|
|
820
|
+
artifacts,
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The plain (non-Response) fetcher path never observes stream chunks, so
|
|
826
|
+
* the terminal snapshot is built here from the fetcher's own result. A
|
|
827
|
+
* stale `error` from a previous run is intentionally dropped — this run
|
|
828
|
+
* succeeded.
|
|
829
|
+
*/
|
|
830
|
+
private completePlainFetcherResumeSnapshot(rawResult: unknown): void {
|
|
831
|
+
const previous = this.resumeSnapshot
|
|
832
|
+
const result = createGenerationResultSnapshot(rawResult)
|
|
833
|
+
this.resumeSnapshot = {
|
|
834
|
+
schemaVersion: 1,
|
|
835
|
+
resumeState: null,
|
|
836
|
+
status: 'complete',
|
|
837
|
+
...(previous?.activity ? { activity: previous.activity } : {}),
|
|
838
|
+
...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
|
|
839
|
+
? { pendingArtifacts: [...previous.pendingArtifacts] }
|
|
840
|
+
: {}),
|
|
841
|
+
...(result
|
|
842
|
+
? { result }
|
|
843
|
+
: previous?.result
|
|
844
|
+
? { result: { ...previous.result } }
|
|
845
|
+
: {}),
|
|
846
|
+
}
|
|
847
|
+
this.notifyResumeSnapshotChanged()
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Records a transport-level failure (network drop, throwing callback) in
|
|
852
|
+
* the snapshot. Without this, only a server-emitted RUN_ERROR chunk would
|
|
853
|
+
* mark the snapshot `error`, leaving a persisted record that claims the
|
|
854
|
+
* run is still in flight.
|
|
855
|
+
*/
|
|
856
|
+
private recordResumeSnapshotError(error: Error): void {
|
|
857
|
+
// Surface the failure on the observable fields FIRST, unconditionally (see
|
|
858
|
+
// the note in GenerationClient.recordResumeSnapshotError): a RUN_ERROR
|
|
859
|
+
// already flipped the snapshot to `error`, so the early-return would else
|
|
860
|
+
// skip this and leave `status` stuck on `generating`. The guard avoids a
|
|
861
|
+
// duplicate `error` emission on the live `generate()` path.
|
|
862
|
+
if (this.status !== 'error') this.setStatus('error')
|
|
863
|
+
this.setError(error)
|
|
864
|
+
if (this.resumeSnapshot?.status === 'error') return
|
|
865
|
+
if (!this.resumeSnapshot && !this.serverDriven) return
|
|
866
|
+
const previous = this.resumeSnapshot
|
|
867
|
+
this.resumeSnapshot = {
|
|
868
|
+
schemaVersion: 1,
|
|
869
|
+
resumeState: null,
|
|
870
|
+
status: 'error',
|
|
871
|
+
...(previous?.activity ? { activity: previous.activity } : {}),
|
|
872
|
+
...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
|
|
873
|
+
? { pendingArtifacts: [...previous.pendingArtifacts] }
|
|
874
|
+
: {}),
|
|
875
|
+
...(previous?.result ? { result: { ...previous.result } } : {}),
|
|
876
|
+
error: { message: error.message },
|
|
877
|
+
}
|
|
878
|
+
this.notifyResumeSnapshotChanged()
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Drop the client's in-memory snapshot and re-emit. Purely local — this
|
|
883
|
+
* client writes no storage, so nothing persisted is removed.
|
|
884
|
+
*/
|
|
885
|
+
private clearResumeSnapshot(): void {
|
|
886
|
+
this.resumeSnapshot = undefined
|
|
887
|
+
this.notifyResumeSnapshotChanged()
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Server-driven mount hydration entry point (`persistence: true`). Runs at
|
|
892
|
+
* most once, from the commit-phase mount path (`mountDevtools`) — never the
|
|
893
|
+
* constructor / render phase — so remounts and speculative renders can't
|
|
894
|
+
* re-fire the hydrate GET.
|
|
895
|
+
*/
|
|
896
|
+
private maybeHydrateFromServer(): void {
|
|
897
|
+
if (!this.serverDriven || this.serverHydrationStarted) return
|
|
898
|
+
this.serverHydrationStarted = true
|
|
899
|
+
if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) {
|
|
900
|
+
this.hydrateFromServer()
|
|
901
|
+
} else {
|
|
902
|
+
// `persistence: true` without any hydrate source can never restore
|
|
903
|
+
// anything — warn rather than silently no-op.
|
|
904
|
+
console.warn(
|
|
905
|
+
'[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.',
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Server-driven mount hydration (`persistence: true`). The client holds no
|
|
912
|
+
* local snapshot; on mount it asks the server — keyed by the stable threadId —
|
|
913
|
+
* for the last generation's resume snapshot, validates it, and repaints it. It
|
|
914
|
+
* never auto-starts a run, and never blocks: a `generate()` that starts first
|
|
915
|
+
* owns the client and hydration backs off, mirroring the chat client.
|
|
916
|
+
*
|
|
917
|
+
* A genuine **miss** (no record for the thread) is silent; a genuine
|
|
918
|
+
* **failure** (transport error, authorize rejection, malformed body, a record
|
|
919
|
+
* the validator rejects) surfaces through `status` / `error` / `onError` — see
|
|
920
|
+
* the note on `GenerationClient.hydrateFromServer`.
|
|
921
|
+
*/
|
|
922
|
+
private hydrateFromServer(): void {
|
|
923
|
+
const hydrate =
|
|
924
|
+
this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler
|
|
925
|
+
if (!hydrate) return
|
|
926
|
+
// A send that already started owns the client; don't stomp it.
|
|
927
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
|
|
928
|
+
void (async () => {
|
|
929
|
+
let res: GenerationHydrationResult
|
|
930
|
+
try {
|
|
931
|
+
res = await hydrate(this.threadId)
|
|
932
|
+
} catch (cause) {
|
|
933
|
+
this.failHydration(
|
|
934
|
+
createGenerationHydrationError(
|
|
935
|
+
'the request to the server did not succeed',
|
|
936
|
+
cause,
|
|
937
|
+
),
|
|
938
|
+
)
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
// No record for this thread — a fresh thread, not a failure.
|
|
942
|
+
if (!res.resumeSnapshot) return
|
|
943
|
+
const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot)
|
|
944
|
+
if (!snapshot) {
|
|
945
|
+
this.failHydration(
|
|
946
|
+
createGenerationHydrationError(
|
|
947
|
+
'the server returned a record this client cannot read (unknown schema version, or a missing/invalid `status` or `resumeState`)',
|
|
948
|
+
),
|
|
949
|
+
)
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
// Re-check: a send may have started while the fetch was in flight.
|
|
953
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle')
|
|
954
|
+
return
|
|
955
|
+
// A run still generating on the server: re-attach and finish it in place.
|
|
956
|
+
this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId)
|
|
957
|
+
})()
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Surface a hydration failure on the observable fields. Skipped when a
|
|
962
|
+
* `generate()` took ownership while the hydrate GET was in flight.
|
|
963
|
+
*/
|
|
964
|
+
private failHydration(error: Error): void {
|
|
965
|
+
if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
|
|
966
|
+
this.setStatus('error')
|
|
967
|
+
this.setError(error)
|
|
968
|
+
this.callbacksRef.onError?.(error)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Re-attach to an already-loaded `running` snapshot (remount case); see the
|
|
973
|
+
* note in GenerationClient.maybeResumeInFlight. Guarded by `rejoinInFlight`.
|
|
974
|
+
*/
|
|
975
|
+
private maybeResumeInFlight(): void {
|
|
976
|
+
if (this.resumeSnapshot?.status !== 'running') return
|
|
977
|
+
const runId = this.resumeSnapshot.resumeState?.runId
|
|
978
|
+
if (runId) this.rejoinInFlight(runId)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Re-attach to a video run still generating and stream it to completion,
|
|
983
|
+
* mirroring the chat client's mount-time rejoin. Reuses `processStream`, so
|
|
984
|
+
* the job status and result repaint from the replayed chunks. A live
|
|
985
|
+
* `generate()` owns the client and is never stomped; a run is rejoined once.
|
|
986
|
+
*/
|
|
987
|
+
private rejoinInFlight(runId: string): void {
|
|
988
|
+
const joinRun = this.connection?.joinRun ?? this.joinRunHandler
|
|
989
|
+
if (!joinRun) return
|
|
990
|
+
if (this.rejoinedRunId === runId) return
|
|
991
|
+
if (this.isLoading || this.abortController) return
|
|
992
|
+
this.rejoinedRunId = runId
|
|
993
|
+
const controller = new AbortController()
|
|
994
|
+
this.abortController = controller
|
|
995
|
+
this.setIsLoading(true)
|
|
996
|
+
this.setStatus('generating')
|
|
997
|
+
void (async () => {
|
|
998
|
+
try {
|
|
999
|
+
await this.processStream(
|
|
1000
|
+
joinRun(runId, controller.signal),
|
|
1001
|
+
runId,
|
|
1002
|
+
controller.signal,
|
|
1003
|
+
)
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (!controller.signal.aborted) {
|
|
1006
|
+
const failure =
|
|
1007
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1008
|
+
// Settles `status`/`error` AND rewrites the snapshot to a terminal
|
|
1009
|
+
// `error` with a null `resumeState`, so the next mount does not
|
|
1010
|
+
// rejoin this run again.
|
|
1011
|
+
this.recordResumeSnapshotError(failure)
|
|
1012
|
+
this.callbacksRef.onError?.(failure)
|
|
1013
|
+
}
|
|
1014
|
+
} finally {
|
|
1015
|
+
// Only reset if this rejoin still owns the client: a `stop()` +
|
|
1016
|
+
// fresh `generate()` may have replaced the controller while the tail
|
|
1017
|
+
// was settling, and that live run owns `isLoading` now.
|
|
1018
|
+
if (this.abortController === controller) {
|
|
1019
|
+
this.abortController = null
|
|
1020
|
+
this.setIsLoading(false)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
})()
|
|
1024
|
+
}
|
|
559
1025
|
}
|
package/dist/esm/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;"}
|