@tanstack/ai-client 0.22.0 → 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-types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
MediaPrompt,
|
|
3
|
+
PersistedArtifactRef,
|
|
4
|
+
StreamChunk,
|
|
5
|
+
} from '@tanstack/ai/client'
|
|
6
|
+
import type { TokenUsage, TranscriptionResponseFormat } from '@tanstack/ai'
|
|
3
7
|
import type { ConnectConnectionAdapter } from './connection-adapters'
|
|
4
8
|
import type { AIDevtoolsClientMetadata } from './devtools'
|
|
5
9
|
import type {
|
|
@@ -58,6 +62,204 @@ export type InferGenerationOutput<TResult, TFn> = TFn extends (
|
|
|
58
62
|
*/
|
|
59
63
|
export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Status of a persisted/restored generation run.
|
|
67
|
+
*
|
|
68
|
+
* `running` / `complete` / `error` are the three the server-side mapper emits
|
|
69
|
+
* over the wire. `idle` is client-local only: `stop()` rewrites a `running`
|
|
70
|
+
* snapshot to it so a cancelled run is no longer resumable.
|
|
71
|
+
*
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Thrown when a generation stream ends without a terminal `RUN_FINISHED` /
|
|
78
|
+
* `RUN_ERROR` chunk — a proxy/load-balancer idle timeout, a server restart
|
|
79
|
+
* mid-run, or a durable log whose terminal append never landed. The run's
|
|
80
|
+
* outcome is unknowable from the client, so it settles as an error rather than
|
|
81
|
+
* leaving the client stuck on `generating` forever.
|
|
82
|
+
*/
|
|
83
|
+
export const GENERATION_STREAM_TRUNCATED_MESSAGE =
|
|
84
|
+
'The generation stream ended before the run finished (no RUN_FINISHED or RUN_ERROR was received) — the connection was interrupted. Generate again to retry.'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reported when a restored snapshot says the run completed but the activity's
|
|
88
|
+
* `reconstructResult` mapper cannot rebuild a result from it — typically an
|
|
89
|
+
* output artifact persisted without a serve `url`. Surfacing it beats a
|
|
90
|
+
* `success` status with a `null` result, which no consumer can render.
|
|
91
|
+
*/
|
|
92
|
+
export const GENERATION_UNRESTORABLE_RESULT_MESSAGE =
|
|
93
|
+
'The stored generation completed but its result could not be rebuilt from the persisted record (its output artifact carries no serve URL, or the fields this activity needs were not persisted). Generate again to produce a fresh result.'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wrap a mount-hydration failure with context. Genuine failures (transport
|
|
97
|
+
* error, a 403 from the authorize gate, an unparseable body, a record the
|
|
98
|
+
* client's validator rejects) must reach the app; only a genuine miss — the
|
|
99
|
+
* server reporting no record for the thread — stays silent.
|
|
100
|
+
*/
|
|
101
|
+
export function createGenerationHydrationError(
|
|
102
|
+
detail: string,
|
|
103
|
+
cause?: unknown,
|
|
104
|
+
): Error {
|
|
105
|
+
const suffix = cause instanceof Error ? `: ${cause.message}` : ''
|
|
106
|
+
const error = new Error(
|
|
107
|
+
`[TanStack AI] Restoring the last generation for this thread failed — ${detail}${suffix}`,
|
|
108
|
+
)
|
|
109
|
+
if (cause !== undefined) {
|
|
110
|
+
error.cause = cause
|
|
111
|
+
}
|
|
112
|
+
return error
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Map a persisted resume status to the client's live state machine on restore:
|
|
117
|
+
* complete → success, error → error, running → generating, idle → idle. A
|
|
118
|
+
* restored `running` only reaches this mapping when a `joinRun` handler can
|
|
119
|
+
* tail the run to completion; without one the client rewrites the snapshot to
|
|
120
|
+
* `error` (interrupted) before repainting, so it never sticks on `generating`.
|
|
121
|
+
*/
|
|
122
|
+
export function clientStateFromResumeStatus(
|
|
123
|
+
status: GenerationResumeStatus,
|
|
124
|
+
): GenerationClientState {
|
|
125
|
+
switch (status) {
|
|
126
|
+
case 'complete':
|
|
127
|
+
return 'success'
|
|
128
|
+
case 'error':
|
|
129
|
+
return 'error'
|
|
130
|
+
case 'running':
|
|
131
|
+
return 'generating'
|
|
132
|
+
case 'idle':
|
|
133
|
+
return 'idle'
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @internal */
|
|
138
|
+
export interface GenerationResumeState {
|
|
139
|
+
threadId: string
|
|
140
|
+
runId: string
|
|
141
|
+
/**
|
|
142
|
+
* Artifact refs observed while the run is still in flight. Non-null only while
|
|
143
|
+
* a run is streaming (`resumeState` itself is null once it ends); the final
|
|
144
|
+
* refs move onto `result.artifacts` when the run completes.
|
|
145
|
+
*/
|
|
146
|
+
pendingArtifacts?: Array<PersistedArtifactRef>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @internal */
|
|
150
|
+
export interface GenerationResultSnapshot {
|
|
151
|
+
id?: string
|
|
152
|
+
model?: string
|
|
153
|
+
status?: string
|
|
154
|
+
/**
|
|
155
|
+
* The provider's async job handle (e.g. a Veo/fal video job id used for
|
|
156
|
+
* status polling) — NOT the generation's own `runId`, which lives on
|
|
157
|
+
* {@link GenerationResumeState.runId}.
|
|
158
|
+
*/
|
|
159
|
+
providerJobId?: string
|
|
160
|
+
expiresAt?: string
|
|
161
|
+
/**
|
|
162
|
+
* The text output of a text activity (a transcription's `text` or a summary's
|
|
163
|
+
* `summary`). Persisted so a text generation restores its result on reload
|
|
164
|
+
* (text is small and not bytes). Absent for media activities, whose output
|
|
165
|
+
* restores from `artifacts`.
|
|
166
|
+
*/
|
|
167
|
+
text?: string
|
|
168
|
+
/** Token usage, persisted so a text result that requires it can be rebuilt. */
|
|
169
|
+
usage?: TokenUsage
|
|
170
|
+
artifacts?: Array<PersistedArtifactRef>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @internal */
|
|
174
|
+
export interface GenerationErrorSnapshot {
|
|
175
|
+
message: string
|
|
176
|
+
code?: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @internal */
|
|
180
|
+
export interface GenerationEventSnapshot {
|
|
181
|
+
type: StreamChunk['type']
|
|
182
|
+
name?: string
|
|
183
|
+
timestamp?: number
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @internal */
|
|
187
|
+
export interface GenerationResumeSnapshot {
|
|
188
|
+
/**
|
|
189
|
+
* Version of the snapshot shape. Written on every snapshot the client builds
|
|
190
|
+
* so future shape changes can migrate (or reject) an older record hydrated
|
|
191
|
+
* from the server. Absent means `1`.
|
|
192
|
+
*/
|
|
193
|
+
schemaVersion?: 1
|
|
194
|
+
resumeState: GenerationResumeState | null
|
|
195
|
+
status: GenerationResumeStatus
|
|
196
|
+
activity?: PersistedArtifactRef['source']['activity']
|
|
197
|
+
pendingArtifacts?: Array<PersistedArtifactRef>
|
|
198
|
+
result?: GenerationResultSnapshot
|
|
199
|
+
error?: GenerationErrorSnapshot
|
|
200
|
+
lastEvent?: GenerationEventSnapshot
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The `persistence` / `threadId` / `id` identity shared by every generation hook.
|
|
205
|
+
*
|
|
206
|
+
* Turning persistence on **requires** a `threadId`, the stable scope runs are
|
|
207
|
+
* filed under. Without one the client would hydrate by a generated id that
|
|
208
|
+
* changes every reload, so nothing would ever restore; making it a type error
|
|
209
|
+
* means the compiler asks for the scope instead of the runtime inventing one.
|
|
210
|
+
*
|
|
211
|
+
* `threadId` is the single identity for the hook, the AG-UI wire thread, and
|
|
212
|
+
* persistence. Legacy `id` is deprecated and typed `never` whenever
|
|
213
|
+
* `threadId` is supplied — pass one scope, not two.
|
|
214
|
+
*
|
|
215
|
+
* Ephemeral generations (no `persistence`, or `persistence: false`) may still
|
|
216
|
+
* pass a deprecated `id` when they have no `threadId`, as a wire/devtools
|
|
217
|
+
* fallback. Prefer giving them a `threadId` instead.
|
|
218
|
+
*
|
|
219
|
+
* USAGE: intersect this onto a hook's parameter and subtract the three keys
|
|
220
|
+
* from the options interface, leaving that interface a plain (non-union)
|
|
221
|
+
* object so `Pick` / `Omit` composition elsewhere keeps working:
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* options: Omit<UseGenerateImageOptions, 'onResult' | 'persistence' | 'threadId' | 'id'> & {
|
|
225
|
+
* onResult?: (result: ImageGenerationResult) => TTransformed
|
|
226
|
+
* } & GenerationPersistenceOptions
|
|
227
|
+
* ```
|
|
228
|
+
*
|
|
229
|
+
* Do NOT bake the union into the options interface itself: a later plain `Omit`
|
|
230
|
+
* over a union collapses it to a single object type and the requirement
|
|
231
|
+
* silently disappears. `use-generation-persistence-types.test.ts` pins this.
|
|
232
|
+
*/
|
|
233
|
+
export type GenerationPersistenceOptions =
|
|
234
|
+
| {
|
|
235
|
+
persistence: true
|
|
236
|
+
/** Required by `persistence` — the stable scope runs are filed under. */
|
|
237
|
+
threadId: string
|
|
238
|
+
/**
|
|
239
|
+
* @deprecated Prefer `threadId`. Not allowed when `threadId` is set —
|
|
240
|
+
* `threadId` is the single identity for the hook, the wire, and persistence.
|
|
241
|
+
*/
|
|
242
|
+
id?: never
|
|
243
|
+
}
|
|
244
|
+
| {
|
|
245
|
+
persistence?: false | undefined
|
|
246
|
+
/** Stable scope for the generation slot (also the wire / devtools identity). */
|
|
247
|
+
threadId: string
|
|
248
|
+
/**
|
|
249
|
+
* @deprecated Prefer `threadId`. Not allowed when `threadId` is set.
|
|
250
|
+
*/
|
|
251
|
+
id?: never
|
|
252
|
+
}
|
|
253
|
+
| {
|
|
254
|
+
persistence?: false | undefined
|
|
255
|
+
threadId?: undefined
|
|
256
|
+
/**
|
|
257
|
+
* @deprecated Prefer `threadId` as the single identity. Only allowed when
|
|
258
|
+
* `threadId` is omitted — legacy wire/devtools fallback for ephemeral runs.
|
|
259
|
+
*/
|
|
260
|
+
id?: string
|
|
261
|
+
}
|
|
262
|
+
|
|
61
263
|
// ===========================
|
|
62
264
|
// Event Constants
|
|
63
265
|
// ===========================
|
|
@@ -70,6 +272,8 @@ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'
|
|
|
70
272
|
export const GENERATION_EVENTS = {
|
|
71
273
|
/** The generation result payload */
|
|
72
274
|
RESULT: 'generation:result',
|
|
275
|
+
/** Persisted artifact refs for generated media */
|
|
276
|
+
ARTIFACTS: 'generation:artifacts',
|
|
73
277
|
/** Progress update (0-100) with optional message */
|
|
74
278
|
PROGRESS: 'generation:progress',
|
|
75
279
|
/** Video job created with jobId */
|
|
@@ -126,15 +330,87 @@ export type GenerationTransport<TInput, TResult> =
|
|
|
126
330
|
*/
|
|
127
331
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference)
|
|
128
332
|
export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
|
|
129
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* @deprecated Prefer {@link GenerationClientOptions.threadId}. Legacy instance
|
|
335
|
+
* id used only as a wire/devtools fallback when `threadId` is omitted. When
|
|
336
|
+
* both are passed, `threadId` wins and `id` is ignored. Framework hooks type
|
|
337
|
+
* `id` as `never` whenever `threadId` is set — see
|
|
338
|
+
* {@link GenerationPersistenceOptions}.
|
|
339
|
+
*/
|
|
130
340
|
id?: string
|
|
131
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The **scope** this generation belongs to: a stable, app-chosen name for the
|
|
344
|
+
* slot successive runs fill, not a link to a chat conversation. This is the
|
|
345
|
+
* single identity for the client — wire thread id, devtools hook id, and
|
|
346
|
+
* persistence key.
|
|
347
|
+
*
|
|
348
|
+
* A generation hook starts empty and produces many runs over its life — each
|
|
349
|
+
* run gets its own `runId`, but they all belong to one scope. Persistence
|
|
350
|
+
* keys on this: server-driven hydrates the last run for it on mount. It is
|
|
351
|
+
* also sent as the AG-UI thread id on the wire, since the protocol requires
|
|
352
|
+
* one.
|
|
353
|
+
*
|
|
354
|
+
* Derive it from your own domain — it must be meaningful before any media
|
|
355
|
+
* exists and identical after a reload:
|
|
356
|
+
*
|
|
357
|
+
* ```ts
|
|
358
|
+
* threadId: `video-${videoId}-start-frame`
|
|
359
|
+
* ```
|
|
360
|
+
*
|
|
361
|
+
* **Required whenever `persistence` is set.** An app that cannot name the
|
|
362
|
+
* scope has nothing to restore *to*, and a generated fallback would key each
|
|
363
|
+
* reload differently — silently restoring nothing. Optional only for
|
|
364
|
+
* ephemeral runs, where it falls back to deprecated `id` (or a generated id)
|
|
365
|
+
* purely to satisfy the wire and nothing is written.
|
|
366
|
+
*/
|
|
367
|
+
threadId?: string
|
|
368
|
+
|
|
132
369
|
/** Additional body parameters to send with connect-based adapter requests */
|
|
133
370
|
body?: Record<string, any>
|
|
134
371
|
|
|
135
372
|
/** Metadata used to register this generation hook with TanStack AI Devtools */
|
|
136
373
|
devtools?: Partial<AIDevtoolsClientMetadata>
|
|
137
374
|
|
|
375
|
+
/**
|
|
376
|
+
* How this generation persists across reloads.
|
|
377
|
+
*
|
|
378
|
+
* - Omit or `false`: ephemeral, in-memory only.
|
|
379
|
+
* - `true`: server-driven. On mount the client hydrates the last generation
|
|
380
|
+
* for its `threadId` from the server (needs a `hydrateGeneration` handler,
|
|
381
|
+
* from the connection or the option below) and repaints that snapshot. It
|
|
382
|
+
* never auto-starts a run.
|
|
383
|
+
*
|
|
384
|
+
* The record lives on the server, written by `withGenerationPersistence`. The
|
|
385
|
+
* browser caches nothing, so a generation's history is never duplicated into
|
|
386
|
+
* client storage.
|
|
387
|
+
*/
|
|
388
|
+
persistence?: boolean
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Server-driven hydration handler, for transports that don't carry one on
|
|
392
|
+
* the connection: supply it alongside `fetcher` (or a `stream()` /
|
|
393
|
+
* `rpcStream()` connection built without handlers) so `persistence: true`
|
|
394
|
+
* can restore the last generation for `threadId` on mount. Typically a
|
|
395
|
+
* one-line TanStack Start server-function call backed by
|
|
396
|
+
* `getGenerationHydration` from `@tanstack/ai-persistence`.
|
|
397
|
+
*
|
|
398
|
+
* A connection's own `hydrateGeneration` takes precedence when both exist.
|
|
399
|
+
*/
|
|
400
|
+
hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration']
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Re-attach handler for a run that is still generating, for transports that
|
|
404
|
+
* don't carry one on the connection. The client tails this on mount when a
|
|
405
|
+
* restored/hydrated snapshot reports a run in flight, replaying it to
|
|
406
|
+
* completion in place. Without it, a restored `running` snapshot surfaces
|
|
407
|
+
* as an (interrupted) error — an interrupted generation cannot be resumed,
|
|
408
|
+
* only re-run.
|
|
409
|
+
*
|
|
410
|
+
* A connection's own `joinRun` takes precedence when both exist.
|
|
411
|
+
*/
|
|
412
|
+
joinRun?: ConnectConnectionAdapter['joinRun']
|
|
413
|
+
|
|
138
414
|
/**
|
|
139
415
|
* Factory that constructs the devtools bridge. Default is a no-op
|
|
140
416
|
* factory; the real implementation lives in `@tanstack/ai-client/devtools`.
|
|
@@ -166,6 +442,198 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
|
|
|
166
442
|
onErrorChange?: (error: Error | undefined) => void
|
|
167
443
|
/** @internal Called when generation status changes */
|
|
168
444
|
onStatusChange?: (status: GenerationClientState) => void
|
|
445
|
+
/** @internal Called when lightweight resume snapshot changes. Receives `undefined` when the snapshot is cleared by `reset()`. */
|
|
446
|
+
onResumeSnapshotChange?: (
|
|
447
|
+
snapshot: GenerationResumeSnapshot | undefined,
|
|
448
|
+
) => void
|
|
449
|
+
/** @internal Called when the in-flight run identity changes. `null` once no run is in flight. Mirrors the chat client's resume-state callback. */
|
|
450
|
+
onResumeStateChange?: (resumeState: GenerationResumeState | null) => void
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @internal Rebuild a typed result from a restored snapshot, injected by each
|
|
454
|
+
* specialized client/hook (which knows the concrete result shape). Called on
|
|
455
|
+
* mount restore (client store or server hydrate) so `result` repaints as if the
|
|
456
|
+
* run had just finished, with media resolved to the durable serve URL. Returns
|
|
457
|
+
* `null` when the snapshot cannot rebuild a result (then `result` stays null;
|
|
458
|
+
* `status` / `error` / `resumeState` still repaint).
|
|
459
|
+
*/
|
|
460
|
+
reconstructResult?: (restored: GenerationRestoredResult) => TResult | null
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The restorable shape handed to a client's `reconstructResult` mapper: the
|
|
465
|
+
* result metadata that survived persistence plus the durable artifact refs (each
|
|
466
|
+
* carrying its serve {@link PersistedArtifactRef.url}). The specialized client
|
|
467
|
+
* turns this into its own typed result (image `images`, video `url`, text
|
|
468
|
+
* `text`, ...).
|
|
469
|
+
*/
|
|
470
|
+
export interface GenerationRestoredResult {
|
|
471
|
+
id?: string
|
|
472
|
+
model?: string
|
|
473
|
+
status?: string
|
|
474
|
+
/** The provider's async job handle — see {@link GenerationResultSnapshot.providerJobId}. */
|
|
475
|
+
providerJobId?: string
|
|
476
|
+
expiresAt?: string
|
|
477
|
+
text?: string
|
|
478
|
+
usage?: TokenUsage
|
|
479
|
+
activity?: PersistedArtifactRef['source']['activity']
|
|
480
|
+
artifacts: Array<PersistedArtifactRef>
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Reduces one observed stream chunk into the lightweight resume snapshot.
|
|
485
|
+
*
|
|
486
|
+
* A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` /
|
|
487
|
+
* `pendingArtifacts` from a previous run are dropped rather than carried into
|
|
488
|
+
* the new run's snapshot.
|
|
489
|
+
*
|
|
490
|
+
* @internal
|
|
491
|
+
*/
|
|
492
|
+
export function updateGenerationResumeSnapshot(
|
|
493
|
+
previous: GenerationResumeSnapshot | null | undefined,
|
|
494
|
+
chunk: StreamChunk,
|
|
495
|
+
): GenerationResumeSnapshot {
|
|
496
|
+
const threadId = stringField(chunk, 'threadId')
|
|
497
|
+
const runId = stringField(chunk, 'runId')
|
|
498
|
+
const carried = chunk.type === 'RUN_STARTED' ? undefined : previous
|
|
499
|
+
const previousArtifacts = carried?.pendingArtifacts ?? []
|
|
500
|
+
const next: GenerationResumeSnapshot = {
|
|
501
|
+
schemaVersion: 1,
|
|
502
|
+
resumeState: carried?.resumeState ?? null,
|
|
503
|
+
status: carried?.status ?? 'idle',
|
|
504
|
+
...(carried?.activity ? { activity: carried.activity } : {}),
|
|
505
|
+
...(previousArtifacts.length > 0
|
|
506
|
+
? { pendingArtifacts: [...previousArtifacts] }
|
|
507
|
+
: {}),
|
|
508
|
+
...(carried?.result ? { result: { ...carried.result } } : {}),
|
|
509
|
+
...(carried?.error ? { error: { ...carried.error } } : {}),
|
|
510
|
+
lastEvent: createGenerationEventSnapshot(chunk),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (threadId && runId) {
|
|
514
|
+
next.resumeState = { threadId, runId }
|
|
515
|
+
next.status = 'running'
|
|
516
|
+
} else if (chunk.type === 'RUN_STARTED') {
|
|
517
|
+
next.status = 'running'
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (chunk.type === 'CUSTOM') {
|
|
521
|
+
if (chunk.name === GENERATION_EVENTS.ARTIFACTS) {
|
|
522
|
+
const artifacts = collectArtifactRefs(chunk.value)
|
|
523
|
+
if (artifacts.length > 0) {
|
|
524
|
+
next.pendingArtifacts = artifacts
|
|
525
|
+
next.activity = artifacts[0]?.source.activity
|
|
526
|
+
}
|
|
527
|
+
} else if (chunk.name === GENERATION_EVENTS.RESULT) {
|
|
528
|
+
const result = createGenerationResultSnapshot(chunk.value)
|
|
529
|
+
if (result) {
|
|
530
|
+
next.result = result
|
|
531
|
+
if (result.artifacts && result.artifacts.length > 0) {
|
|
532
|
+
next.pendingArtifacts = result.artifacts
|
|
533
|
+
next.activity = result.artifacts[0]?.source.activity
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
|
|
537
|
+
// Capture the provider job id as soon as the job exists — for a long
|
|
538
|
+
// video run this is the one piece of identity worth having after a
|
|
539
|
+
// reload, and the terminal `generation:result` may never arrive.
|
|
540
|
+
const providerJobId = isObject(chunk.value)
|
|
541
|
+
? stringField(chunk.value, 'jobId')
|
|
542
|
+
: undefined
|
|
543
|
+
if (providerJobId) {
|
|
544
|
+
next.result = { ...next.result, providerJobId }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} else if (chunk.type === 'RUN_FINISHED') {
|
|
548
|
+
next.resumeState = null
|
|
549
|
+
next.status = 'complete'
|
|
550
|
+
} else if (chunk.type === 'RUN_ERROR') {
|
|
551
|
+
next.resumeState = null
|
|
552
|
+
next.status = 'error'
|
|
553
|
+
next.error = createGenerationErrorSnapshot(chunk)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return next
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Validates an untrusted value (a hydration body resolved by the server) into a
|
|
561
|
+
* {@link GenerationResumeSnapshot}, or returns `undefined` when the value is
|
|
562
|
+
* not a usable snapshot.
|
|
563
|
+
*
|
|
564
|
+
* A hydrated record is outside the type system: it may be stale, truncated, or
|
|
565
|
+
* written by a different version. Every field is re-validated with the same
|
|
566
|
+
* narrowing the live chunk reducer uses. `lastEvent` is not restored, since it
|
|
567
|
+
* describes a transient stream position with no meaning after a reload.
|
|
568
|
+
*
|
|
569
|
+
* @internal
|
|
570
|
+
*/
|
|
571
|
+
export function parseGenerationResumeSnapshot(
|
|
572
|
+
value: unknown,
|
|
573
|
+
): GenerationResumeSnapshot | undefined {
|
|
574
|
+
if (!isObject(value)) return undefined
|
|
575
|
+
|
|
576
|
+
const schemaVersion = Reflect.get(value, 'schemaVersion')
|
|
577
|
+
if (schemaVersion !== undefined && schemaVersion !== 1) return undefined
|
|
578
|
+
|
|
579
|
+
const status = generationResumeStatusField(value, 'status')
|
|
580
|
+
if (!status) return undefined
|
|
581
|
+
|
|
582
|
+
const rawResumeState = Reflect.get(value, 'resumeState')
|
|
583
|
+
let resumeState: GenerationResumeState | null = null
|
|
584
|
+
if (rawResumeState !== null && rawResumeState !== undefined) {
|
|
585
|
+
if (!isObject(rawResumeState)) return undefined
|
|
586
|
+
const threadId = stringField(rawResumeState, 'threadId')
|
|
587
|
+
const runId = stringField(rawResumeState, 'runId')
|
|
588
|
+
if (!threadId || !runId) return undefined
|
|
589
|
+
resumeState = { threadId, runId }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const snapshot: GenerationResumeSnapshot = {
|
|
593
|
+
schemaVersion: 1,
|
|
594
|
+
resumeState,
|
|
595
|
+
status,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const activity = persistedArtifactActivityField(value, 'activity')
|
|
599
|
+
if (activity) snapshot.activity = activity
|
|
600
|
+
|
|
601
|
+
const pendingArtifacts = collectArtifactRefs(
|
|
602
|
+
Reflect.get(value, 'pendingArtifacts'),
|
|
603
|
+
)
|
|
604
|
+
if (pendingArtifacts.length > 0) snapshot.pendingArtifacts = pendingArtifacts
|
|
605
|
+
|
|
606
|
+
const result = createGenerationResultSnapshot(Reflect.get(value, 'result'))
|
|
607
|
+
if (result) snapshot.result = result
|
|
608
|
+
|
|
609
|
+
const rawError = Reflect.get(value, 'error')
|
|
610
|
+
if (isObject(rawError)) {
|
|
611
|
+
const message = stringField(rawError, 'message')
|
|
612
|
+
if (message) {
|
|
613
|
+
const code = stringField(rawError, 'code')
|
|
614
|
+
snapshot.error = { message, ...(code ? { code } : {}) }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return snapshot
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function generationResumeStatusField(
|
|
622
|
+
value: object,
|
|
623
|
+
key: string,
|
|
624
|
+
): GenerationResumeStatus | undefined {
|
|
625
|
+
const field = stringField(value, key)
|
|
626
|
+
if (field === undefined) return undefined
|
|
627
|
+
|
|
628
|
+
switch (field) {
|
|
629
|
+
case 'idle':
|
|
630
|
+
case 'running':
|
|
631
|
+
case 'complete':
|
|
632
|
+
case 'error':
|
|
633
|
+
return field
|
|
634
|
+
default:
|
|
635
|
+
return undefined
|
|
636
|
+
}
|
|
169
637
|
}
|
|
170
638
|
|
|
171
639
|
// ===========================
|
|
@@ -200,6 +668,8 @@ export interface VideoGenerateResult {
|
|
|
200
668
|
url: string
|
|
201
669
|
/** When the URL expires, if applicable */
|
|
202
670
|
expiresAt?: Date
|
|
671
|
+
/** Persisted artifact references for generated assets, when available */
|
|
672
|
+
artifacts?: Array<PersistedArtifactRef>
|
|
203
673
|
}
|
|
204
674
|
|
|
205
675
|
/**
|
|
@@ -328,3 +798,257 @@ export interface VideoGenerateInput {
|
|
|
328
798
|
/** Model-specific options */
|
|
329
799
|
modelOptions?: Record<string, any>
|
|
330
800
|
}
|
|
801
|
+
|
|
802
|
+
function createGenerationEventSnapshot(
|
|
803
|
+
chunk: StreamChunk,
|
|
804
|
+
): GenerationEventSnapshot {
|
|
805
|
+
const name = stringField(chunk, 'name')
|
|
806
|
+
const timestamp = numberField(chunk, 'timestamp')
|
|
807
|
+
return {
|
|
808
|
+
type: chunk.type,
|
|
809
|
+
...(name ? { name } : {}),
|
|
810
|
+
...(timestamp !== undefined ? { timestamp } : {}),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** @internal Narrows an untrusted result payload into the persisted result snapshot shape. */
|
|
815
|
+
export function createGenerationResultSnapshot(
|
|
816
|
+
value: unknown,
|
|
817
|
+
): GenerationResultSnapshot | undefined {
|
|
818
|
+
if (!isObject(value)) return undefined
|
|
819
|
+
|
|
820
|
+
const artifacts = collectArtifactRefs(Reflect.get(value, 'artifacts'))
|
|
821
|
+
const snapshot: GenerationResultSnapshot = {}
|
|
822
|
+
const id = stringField(value, 'id')
|
|
823
|
+
const model = stringField(value, 'model')
|
|
824
|
+
const status = stringField(value, 'status')
|
|
825
|
+
// A live provider result carries its job handle as `jobId` (e.g.
|
|
826
|
+
// `VideoGenerateResult.jobId`); a persisted snapshot carries it as
|
|
827
|
+
// `providerJobId`. Accept both — this narrows raw results AND stored
|
|
828
|
+
// snapshots.
|
|
829
|
+
const providerJobId =
|
|
830
|
+
stringField(value, 'providerJobId') ?? stringField(value, 'jobId')
|
|
831
|
+
// A transcription's output is `text`; a summary's is `summary`. Capture either
|
|
832
|
+
// under `text` so a text result restores on reload.
|
|
833
|
+
const text = stringField(value, 'text') ?? stringField(value, 'summary')
|
|
834
|
+
const usage = Reflect.get(value, 'usage')
|
|
835
|
+
if (id) snapshot.id = id
|
|
836
|
+
if (model) snapshot.model = model
|
|
837
|
+
if (status) snapshot.status = status
|
|
838
|
+
if (providerJobId) snapshot.providerJobId = providerJobId
|
|
839
|
+
if (text) snapshot.text = text
|
|
840
|
+
// Passthrough opaque token-usage metadata (untrusted; not deeply validated).
|
|
841
|
+
if (isObject(usage)) snapshot.usage = usage as TokenUsage
|
|
842
|
+
const expiresAt = Reflect.get(value, 'expiresAt')
|
|
843
|
+
if (typeof expiresAt === 'string') {
|
|
844
|
+
snapshot.expiresAt = expiresAt
|
|
845
|
+
} else if (expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())) {
|
|
846
|
+
// `toISOString()` throws on an invalid Date. This runs per chunk on live
|
|
847
|
+
// provider values, so drop an unusable date like every other bad field
|
|
848
|
+
// here rather than throwing out of the stream loop.
|
|
849
|
+
snapshot.expiresAt = expiresAt.toISOString()
|
|
850
|
+
}
|
|
851
|
+
if (artifacts.length > 0) {
|
|
852
|
+
snapshot.artifacts = artifacts
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return Object.keys(snapshot).length > 0 ? snapshot : undefined
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function createGenerationErrorSnapshot(
|
|
859
|
+
chunk: StreamChunk,
|
|
860
|
+
): GenerationErrorSnapshot {
|
|
861
|
+
const message =
|
|
862
|
+
stringField(chunk, 'message') ??
|
|
863
|
+
nestedStringField(chunk, 'error', 'message') ??
|
|
864
|
+
'An error occurred'
|
|
865
|
+
const code = stringField(chunk, 'code')
|
|
866
|
+
return {
|
|
867
|
+
message,
|
|
868
|
+
...(code ? { code } : {}),
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function collectArtifactRefs(value: unknown): Array<PersistedArtifactRef> {
|
|
873
|
+
if (!Array.isArray(value)) return []
|
|
874
|
+
const refs: Array<PersistedArtifactRef> = []
|
|
875
|
+
for (const item of value) {
|
|
876
|
+
const ref = createPersistedArtifactRefSnapshot(item)
|
|
877
|
+
if (ref) {
|
|
878
|
+
refs.push(ref)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return refs
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function createPersistedArtifactRefSnapshot(
|
|
885
|
+
value: unknown,
|
|
886
|
+
): PersistedArtifactRef | undefined {
|
|
887
|
+
if (!isObject(value)) return undefined
|
|
888
|
+
const source = Reflect.get(value, 'source')
|
|
889
|
+
if (!isObject(source)) return undefined
|
|
890
|
+
|
|
891
|
+
const role = persistedArtifactRoleField(value, 'role')
|
|
892
|
+
const artifactId = stringField(value, 'artifactId')
|
|
893
|
+
const threadId = stringField(value, 'threadId')
|
|
894
|
+
const runId = stringField(value, 'runId')
|
|
895
|
+
const name = stringField(value, 'name')
|
|
896
|
+
const mimeType = stringField(value, 'mimeType')
|
|
897
|
+
const size = numberField(value, 'size')
|
|
898
|
+
const createdAt = stringField(value, 'createdAt')
|
|
899
|
+
const activity = persistedArtifactActivityField(source, 'activity')
|
|
900
|
+
const path = stringField(source, 'path')
|
|
901
|
+
const provider = stringField(source, 'provider')
|
|
902
|
+
const model = stringField(source, 'model')
|
|
903
|
+
if (
|
|
904
|
+
!role ||
|
|
905
|
+
!artifactId ||
|
|
906
|
+
!threadId ||
|
|
907
|
+
!runId ||
|
|
908
|
+
!name ||
|
|
909
|
+
!mimeType ||
|
|
910
|
+
size === undefined ||
|
|
911
|
+
!createdAt ||
|
|
912
|
+
!activity ||
|
|
913
|
+
!path ||
|
|
914
|
+
!provider ||
|
|
915
|
+
!model
|
|
916
|
+
) {
|
|
917
|
+
return undefined
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const sourceUrl = durableUrlField(value, 'sourceUrl')
|
|
921
|
+
const url = serveUrlField(value, 'url')
|
|
922
|
+
const mediaType = persistedArtifactMediaTypeField(source, 'mediaType')
|
|
923
|
+
const jobId = stringField(source, 'jobId')
|
|
924
|
+
const expiresAt = stringField(source, 'expiresAt')
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
role,
|
|
928
|
+
artifactId,
|
|
929
|
+
threadId,
|
|
930
|
+
runId,
|
|
931
|
+
name,
|
|
932
|
+
mimeType,
|
|
933
|
+
size,
|
|
934
|
+
createdAt,
|
|
935
|
+
...(sourceUrl ? { sourceUrl } : {}),
|
|
936
|
+
...(url ? { url } : {}),
|
|
937
|
+
source: {
|
|
938
|
+
activity,
|
|
939
|
+
path,
|
|
940
|
+
provider,
|
|
941
|
+
model,
|
|
942
|
+
...(mediaType ? { mediaType } : {}),
|
|
943
|
+
...(jobId ? { jobId } : {}),
|
|
944
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
945
|
+
},
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function durableUrlField(value: object, key: string): string | undefined {
|
|
950
|
+
const field = stringField(value, key)
|
|
951
|
+
if (!field || field.length > 2048) return undefined
|
|
952
|
+
try {
|
|
953
|
+
const url = new URL(field)
|
|
954
|
+
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
955
|
+
? field
|
|
956
|
+
: undefined
|
|
957
|
+
} catch {
|
|
958
|
+
return undefined
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Validates an app-origin serve URL, which unlike a provider URL is usually a
|
|
964
|
+
* same-origin path (`/api/.../artifact?id=...`). Accepts an absolute http(s) URL
|
|
965
|
+
* or a path-absolute same-origin URL (single leading `/`); rejects
|
|
966
|
+
* protocol-relative (`//host`), `javascript:` / `data:`, and anything else, since
|
|
967
|
+
* this value is rendered as media `src`.
|
|
968
|
+
*/
|
|
969
|
+
function serveUrlField(value: object, key: string): string | undefined {
|
|
970
|
+
const field = stringField(value, key)
|
|
971
|
+
if (!field || field.length > 2048) return undefined
|
|
972
|
+
// A single leading `/` is a safe same-origin path. Reject protocol-relative
|
|
973
|
+
// `//host` AND a backslash bypass (`/\host` — the URL parser treats `\` as `/`
|
|
974
|
+
// for http(s), so it would resolve to a foreign origin as an `<img src>`).
|
|
975
|
+
if (field.startsWith('/') && !field.startsWith('//') && !field.includes('\\'))
|
|
976
|
+
return field
|
|
977
|
+
try {
|
|
978
|
+
const url = new URL(field)
|
|
979
|
+
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
980
|
+
? field
|
|
981
|
+
: undefined
|
|
982
|
+
} catch {
|
|
983
|
+
return undefined
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function persistedArtifactRoleField(
|
|
988
|
+
value: object,
|
|
989
|
+
key: string,
|
|
990
|
+
): PersistedArtifactRef['role'] | undefined {
|
|
991
|
+
const field = stringField(value, key)
|
|
992
|
+
return field === 'input' || field === 'output' ? field : undefined
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function persistedArtifactActivityField(
|
|
996
|
+
value: object,
|
|
997
|
+
key: string,
|
|
998
|
+
): PersistedArtifactRef['source']['activity'] | undefined {
|
|
999
|
+
const field = stringField(value, key)
|
|
1000
|
+
if (field === undefined) return undefined
|
|
1001
|
+
|
|
1002
|
+
switch (field) {
|
|
1003
|
+
case 'image':
|
|
1004
|
+
case 'audio':
|
|
1005
|
+
case 'tts':
|
|
1006
|
+
case 'video':
|
|
1007
|
+
case 'transcription':
|
|
1008
|
+
return field
|
|
1009
|
+
default:
|
|
1010
|
+
return undefined
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function persistedArtifactMediaTypeField(
|
|
1015
|
+
value: object,
|
|
1016
|
+
key: string,
|
|
1017
|
+
): PersistedArtifactRef['source']['mediaType'] | undefined {
|
|
1018
|
+
const field = stringField(value, key)
|
|
1019
|
+
if (field === undefined) return undefined
|
|
1020
|
+
|
|
1021
|
+
switch (field) {
|
|
1022
|
+
case 'image':
|
|
1023
|
+
case 'audio':
|
|
1024
|
+
case 'video':
|
|
1025
|
+
case 'document':
|
|
1026
|
+
case 'json':
|
|
1027
|
+
return field
|
|
1028
|
+
default:
|
|
1029
|
+
return undefined
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function nestedStringField(
|
|
1034
|
+
value: object,
|
|
1035
|
+
key: string,
|
|
1036
|
+
nestedKey: string,
|
|
1037
|
+
): string | undefined {
|
|
1038
|
+
const nested = Reflect.get(value, key)
|
|
1039
|
+
return isObject(nested) ? stringField(nested, nestedKey) : undefined
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function stringField(value: object, key: string): string | undefined {
|
|
1043
|
+
const field = Reflect.get(value, key)
|
|
1044
|
+
return typeof field === 'string' ? field : undefined
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function numberField(value: object, key: string): number | undefined {
|
|
1048
|
+
const field = Reflect.get(value, key)
|
|
1049
|
+
return typeof field === 'number' ? field : undefined
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function isObject(value: unknown): value is object {
|
|
1053
|
+
return typeof value === 'object' && value !== null
|
|
1054
|
+
}
|