@tanstack/ai-remix 0.0.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.
@@ -0,0 +1,94 @@
1
+ import { createGeneration } from './create-generation.ts'
2
+ import { reconstructImageResult } from '@tanstack/ai-client'
3
+ import type { Handle } from 'remix/ui'
4
+ import type { ImageGenerationResult } from '@tanstack/ai'
5
+ import type {
6
+ GenerationPersistenceOptions,
7
+ ImageGenerateInput,
8
+ } from '@tanstack/ai-client'
9
+ import type {
10
+ CreateGenerationOptions,
11
+ CreateGenerationReturn,
12
+ } from './create-generation.ts'
13
+
14
+ /**
15
+ * Options for the createGenerateImage helper.
16
+ *
17
+ * Handle is the first argument of the helper. It is not part of this type.
18
+ *
19
+ * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult)
20
+ */
21
+ export type CreateGenerateImageOptions<TOutput = ImageGenerationResult> = Omit<
22
+ CreateGenerationOptions<ImageGenerateInput, ImageGenerationResult, TOutput>,
23
+ 'onResult' | 'reconstructResult'
24
+ > & {
25
+ onResult?: (result: ImageGenerationResult) => TOutput | null | void
26
+ }
27
+
28
+ /**
29
+ * Return type for the createGenerateImage helper.
30
+ *
31
+ * @template TOutput - The output type (after optional transform)
32
+ */
33
+ export type CreateGenerateImageReturn<TOutput = ImageGenerationResult> =
34
+ CreateGenerationReturn<TOutput, ImageGenerateInput>
35
+
36
+ /**
37
+ * Creates an image generation helper for Remix setup.
38
+ *
39
+ * Supports two transport modes:
40
+ * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom)
41
+ * - **Fetcher** — Direct async function call
42
+ *
43
+ * Call this in a Remix component setup function. Pass the component Handle as
44
+ * the first argument.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * import { createGenerateImage } from '@tanstack/ai-remix'
49
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
50
+ * import type { Handle } from 'remix/ui'
51
+ *
52
+ * function ImageGenerator(handle: Handle) {
53
+ * const image = createGenerateImage(handle, {
54
+ * connection: fetchServerSentEvents('/api/generate/image'),
55
+ * })
56
+ *
57
+ * return () => (
58
+ * <div>
59
+ * <button onClick={() => image.generate({ prompt: 'A sunset over mountains' })}>
60
+ * Generate
61
+ * </button>
62
+ * {image.isLoading ? <p>Generating...</p> : null}
63
+ * {image.result?.images.map((img) => (
64
+ * <img src={img.url || `data:image/png;base64,${img.b64Json}`} />
65
+ * ))}
66
+ * </div>
67
+ * )
68
+ * }
69
+ * ```
70
+ */
71
+ export function createGenerateImage<TTransformed = void>(
72
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
73
+ options: Omit<
74
+ CreateGenerateImageOptions,
75
+ 'onResult' | 'persistence' | 'threadId'
76
+ > & {
77
+ onResult?: (result: ImageGenerationResult) => TTransformed
78
+ } & GenerationPersistenceOptions,
79
+ ) {
80
+ const devtools = {
81
+ ...options.devtools,
82
+ hookName: 'createGenerateImage',
83
+ outputKind: 'image' as const,
84
+ }
85
+ return createGeneration<
86
+ ImageGenerateInput,
87
+ ImageGenerationResult,
88
+ TTransformed
89
+ >(handle, {
90
+ ...options,
91
+ devtools,
92
+ reconstructResult: reconstructImageResult,
93
+ })
94
+ }
@@ -0,0 +1,93 @@
1
+ import { createGeneration } from './create-generation.ts'
2
+ import { reconstructSpeechResult } from '@tanstack/ai-client'
3
+ import type { Handle } from 'remix/ui'
4
+ import type { TTSResult } from '@tanstack/ai'
5
+ import type {
6
+ GenerationPersistenceOptions,
7
+ SpeechGenerateInput,
8
+ } from '@tanstack/ai-client'
9
+ import type {
10
+ CreateGenerationOptions,
11
+ CreateGenerationReturn,
12
+ } from './create-generation.ts'
13
+
14
+ /**
15
+ * Options for the createGenerateSpeech helper.
16
+ *
17
+ * Handle is the first argument of the helper. It is not part of this type.
18
+ *
19
+ * @template TOutput - The output type after optional transform (defaults to TTSResult)
20
+ */
21
+ export type CreateGenerateSpeechOptions<TOutput = TTSResult> = Omit<
22
+ CreateGenerationOptions<SpeechGenerateInput, TTSResult, TOutput>,
23
+ 'onResult' | 'reconstructResult'
24
+ > & {
25
+ onResult?: (result: TTSResult) => TOutput | null | void
26
+ }
27
+
28
+ /**
29
+ * Return type for the createGenerateSpeech helper.
30
+ *
31
+ * @template TOutput - The output type (after optional transform)
32
+ */
33
+ export type CreateGenerateSpeechReturn<TOutput = TTSResult> =
34
+ CreateGenerationReturn<TOutput, SpeechGenerateInput>
35
+
36
+ /**
37
+ * Creates a speech generation (text-to-speech) helper for Remix setup.
38
+ *
39
+ * Call this in a Remix component setup function. Pass the component Handle as
40
+ * the first argument.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * import { createGenerateSpeech } from '@tanstack/ai-remix'
45
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
46
+ * import type { Handle } from 'remix/ui'
47
+ *
48
+ * function SpeechGenerator(handle: Handle) {
49
+ * const speech = createGenerateSpeech(handle, {
50
+ * connection: fetchServerSentEvents('/api/generate/speech'),
51
+ * })
52
+ *
53
+ * return () => (
54
+ * <div>
55
+ * <button
56
+ * onClick={() => speech.generate({ text: 'Hello world', voice: 'alloy' })}
57
+ * >
58
+ * Generate Speech
59
+ * </button>
60
+ * {speech.result ? (
61
+ * <audio
62
+ * src={`data:audio/${speech.result.format};base64,${speech.result.audio}`}
63
+ * controls
64
+ * />
65
+ * ) : null}
66
+ * </div>
67
+ * )
68
+ * }
69
+ * ```
70
+ */
71
+ export function createGenerateSpeech<TTransformed = void>(
72
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
73
+ options: Omit<
74
+ CreateGenerateSpeechOptions,
75
+ 'onResult' | 'persistence' | 'threadId'
76
+ > & {
77
+ onResult?: (result: TTSResult) => TTransformed
78
+ } & GenerationPersistenceOptions,
79
+ ) {
80
+ const devtools = {
81
+ ...options.devtools,
82
+ hookName: 'createGenerateSpeech',
83
+ outputKind: 'audio' as const,
84
+ }
85
+ return createGeneration<SpeechGenerateInput, TTSResult, TTransformed>(
86
+ handle,
87
+ {
88
+ ...options,
89
+ devtools,
90
+ reconstructResult: reconstructSpeechResult,
91
+ },
92
+ )
93
+ }
@@ -0,0 +1,347 @@
1
+ import { VideoGenerationClient } from '@tanstack/ai-client'
2
+ import { createVideoDevtoolsBridge } from '@tanstack/ai-client/devtools'
3
+ import type { Handle } from 'remix/ui'
4
+ import type { StreamChunk } from '@tanstack/ai'
5
+ import type {
6
+ AIDevtoolsDisplayOptions,
7
+ ConnectConnectionAdapter,
8
+ GenerationClientState,
9
+ GenerationFetcher,
10
+ GenerationPersistenceOptions,
11
+ InferGenerationOutputFromReturn,
12
+ VideoGenerateInput,
13
+ VideoGenerateResult,
14
+ VideoGenerationClientOptions,
15
+ VideoStatusInfo,
16
+ } from '@tanstack/ai-client'
17
+ import type { ByokClient } from '@tanstack/ai-client/byok'
18
+ import type { ProviderId } from '@tanstack/ai/byok'
19
+
20
+ /**
21
+ * Options for the createGenerateVideo helper.
22
+ *
23
+ * Handle is the first argument of the helper. It is not part of this type.
24
+ *
25
+ * @template TOutput - The output type after optional transform (defaults to VideoGenerateResult)
26
+ */
27
+ export interface CreateGenerateVideoOptions<TOutput = VideoGenerateResult> {
28
+ /** Connect-based adapter for streaming transport (server handles polling) */
29
+ connection?: ConnectConnectionAdapter
30
+ /** Direct async function that returns a completed video result */
31
+ fetcher?: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
32
+ /** Additional body parameters to send with connect-based adapter requests */
33
+ body?: Record<string, any>
34
+ /** Optional BYOK keyring. Keys go in `x-byok-*` headers, never the body. */
35
+ byok?: ByokClient
36
+ /** Optional provider id. If it returns a slug, only that key is sent. If no slug resolves (`byokProvider`, then `body.provider`), generate throws. */
37
+ byokProvider?: () => ProviderId | undefined
38
+ /** Display options for TanStack AI Devtools. */
39
+ devtools?: AIDevtoolsDisplayOptions
40
+ /**
41
+ * How this generation persists across reloads.
42
+ * - Omit / `false`: ephemeral, in-memory only.
43
+ * - `true`: server-driven — on mount the client hydrates the last generation
44
+ * for its `threadId` from the server (needs a connection with a
45
+ * `hydrateGeneration` handler) and repaints it; it never auto-starts a run.
46
+ */
47
+ persistence?: boolean
48
+ /**
49
+ * The **scope** this generation belongs to: a stable, app-chosen name for the
50
+ * slot successive runs fill — not a link to a chat conversation.
51
+ *
52
+ * The helper starts empty and produces many runs over its life; each gets its
53
+ * own `runId`, but all belong to one scope. Persistence keys on this, so
54
+ * derive it from your own domain and keep it identical across reloads (e.g.
55
+ * `` `video-${videoId}-start-frame` ``). It is also sent as the AG-UI thread
56
+ * id on the wire, which the protocol requires.
57
+ *
58
+ * **Required whenever `persistence` is set** — an app that cannot name the
59
+ * scope has nothing to restore to. Optional for ephemeral generations. If
60
+ * omitted, the helper uses `handle.id`.
61
+ */
62
+ threadId?: string
63
+ /**
64
+ * Server-driven hydration handler for `persistence: true` when the
65
+ * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` /
66
+ * `rpcStream()` adapter built without handlers) — typically a one-line
67
+ * server-function call. The connection's own handler takes precedence.
68
+ */
69
+ hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration']
70
+ /**
71
+ * Re-attach handler that replays a run still generating to completion on
72
+ * mount, when the connection doesn't carry one. Without it, a restored
73
+ * `running` snapshot surfaces as an (interrupted) error. The connection's
74
+ * own handler takes precedence.
75
+ */
76
+ joinRun?: ConnectConnectionAdapter['joinRun']
77
+ /**
78
+ * Callback when video generation completes. Can optionally return a transformed value.
79
+ *
80
+ * - Return a non-null value to transform and store it as the result
81
+ * - Return `null` to keep the previous result unchanged
82
+ * - Return nothing (`void`) to store the raw result as-is
83
+ */
84
+ onResult?: (result: VideoGenerateResult) => TOutput | null | void
85
+ /** Callback when an error occurs */
86
+ onError?: (error: Error) => void
87
+ /** Callback when progress is reported (0-100) */
88
+ onProgress?: (progress: number, message?: string) => void
89
+ /** Callback when a video job is created */
90
+ onJobCreated?: (jobId: string) => void
91
+ /** Callback on each status update */
92
+ onStatusUpdate?: (status: VideoStatusInfo) => void
93
+ /** Callback for each stream chunk (connect-based adapter mode only) */
94
+ onChunk?: (chunk: StreamChunk) => void
95
+ }
96
+
97
+ /**
98
+ * Return type for the createGenerateVideo helper.
99
+ *
100
+ * Fields are getters over local lets. Remix re-renders read the latest values
101
+ * after `handle.update()`.
102
+ *
103
+ * @template TOutput - The output type (after optional transform)
104
+ */
105
+ export interface CreateGenerateVideoReturn<TOutput = VideoGenerateResult> {
106
+ /** The final video result (with URL), or null */
107
+ readonly result: TOutput | null
108
+ /** The current job ID, or null */
109
+ readonly jobId: string | null
110
+ /** Current video generation status info, or null */
111
+ readonly videoStatus: VideoStatusInfo | null
112
+ /** Whether generation/polling is in progress */
113
+ readonly isLoading: boolean
114
+ /** Current error, if any */
115
+ readonly error: Error | undefined
116
+ /** Current state of the generation */
117
+ readonly status: GenerationClientState
118
+ /** Trigger video generation */
119
+ generate: (input: VideoGenerateInput) => Promise<void>
120
+ /** Abort the current generation/polling */
121
+ stop: () => void
122
+ /** Clear all state and return to idle */
123
+ reset: () => void
124
+ /**
125
+ * The id of the generation job currently running, or `null` when nothing is in
126
+ * flight. Each call to `generate` is one job with its own id. Pass it to your
127
+ * own endpoint to cancel or poll the provider job — `stop()` only aborts the
128
+ * local stream, it does not stop work already running on the provider.
129
+ */
130
+ readonly runId: string | null
131
+ }
132
+
133
+ /**
134
+ * Creates a video generation helper for Remix setup.
135
+ *
136
+ * Video generation is asynchronous: a job is created, then polled for status
137
+ * until completion. This helper handles the full lifecycle.
138
+ *
139
+ * Call this in a Remix component setup function. Pass the component Handle as
140
+ * the first argument.
141
+ *
142
+ * @example
143
+ * ```tsx
144
+ * import { createGenerateVideo } from '@tanstack/ai-remix'
145
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
146
+ * import type { Handle } from 'remix/ui'
147
+ *
148
+ * function VideoGenerator(handle: Handle) {
149
+ * const video = createGenerateVideo(handle, {
150
+ * connection: fetchServerSentEvents('/api/generate/video'),
151
+ * onStatusUpdate: (status) => console.log(`Progress: ${status.progress}%`),
152
+ * })
153
+ *
154
+ * return () => (
155
+ * <div>
156
+ * <button onClick={() => video.generate({ prompt: 'A flying car over a city' })}>
157
+ * Generate Video
158
+ * </button>
159
+ * {video.isLoading && video.videoStatus ? (
160
+ * <p>
161
+ * Status: {video.videoStatus.status} ({video.videoStatus.progress}%)
162
+ * </p>
163
+ * ) : null}
164
+ * {video.result ? <video src={video.result.url} controls /> : null}
165
+ * </div>
166
+ * )
167
+ * }
168
+ * ```
169
+ */
170
+ // `TTransformed` infers from the `onResult` return position so the callback
171
+ // parameter is typed as `VideoGenerateResult` and `result` narrows to the
172
+ // transform's return. See issue #848.
173
+ export function createGenerateVideo<TTransformed = void>(
174
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
175
+ options: Omit<
176
+ CreateGenerateVideoOptions,
177
+ 'onResult' | 'persistence' | 'threadId'
178
+ > & {
179
+ onResult?: (result: VideoGenerateResult) => TTransformed
180
+ } & GenerationPersistenceOptions,
181
+ ) {
182
+ type TOutput = InferGenerationOutputFromReturn<
183
+ VideoGenerateResult,
184
+ TTransformed
185
+ >
186
+
187
+ let result: TOutput | null = null
188
+ let jobId: string | null = null
189
+ let videoStatus: VideoStatusInfo | null = null
190
+ let isLoading = false
191
+ let error: Error | undefined = undefined
192
+ let status: GenerationClientState = 'idle'
193
+ let runId: string | null = null
194
+ let disposed = false
195
+
196
+ const notify = () => {
197
+ if (disposed) return
198
+ void handle.update()
199
+ }
200
+
201
+ const threadId = options.threadId ?? handle.id
202
+
203
+ const baseOptions: Omit<
204
+ VideoGenerationClientOptions<TOutput>,
205
+ 'persistence' | 'threadId'
206
+ > = {
207
+ ...(options.body !== undefined && { body: options.body }),
208
+ ...(options.hydrateGeneration !== undefined && {
209
+ hydrateGeneration: options.hydrateGeneration,
210
+ }),
211
+ ...(options.joinRun !== undefined && { joinRun: options.joinRun }),
212
+ ...(options.byok !== undefined && { byok: options.byok }),
213
+ byokProvider: () => options.byokProvider?.(),
214
+ devtoolsBridgeFactory: createVideoDevtoolsBridge,
215
+ devtools: {
216
+ hookName: 'createGenerateVideo',
217
+ ...options.devtools,
218
+ framework: 'remix',
219
+ outputKind: 'video' as const,
220
+ },
221
+ // The transform's raw return type (`TTransformed`) and the stored output
222
+ // (`TOutput`, with null/void/undefined stripped) are identical at runtime;
223
+ // the cast bridges the relationship that the conditional type hides.
224
+ onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as (
225
+ result: VideoGenerateResult,
226
+ ) => TOutput | null | void,
227
+ onError: (e: Error) => {
228
+ if (!disposed) options.onError?.(e)
229
+ },
230
+ onProgress: (p: number, m?: string) => {
231
+ if (!disposed) options.onProgress?.(p, m)
232
+ },
233
+ onChunk: (c: StreamChunk) => {
234
+ if (!disposed) options.onChunk?.(c)
235
+ },
236
+ onJobCreated: (id: string) => {
237
+ if (!disposed) options.onJobCreated?.(id)
238
+ },
239
+ onStatusUpdate: (s: VideoStatusInfo) => {
240
+ if (!disposed) options.onStatusUpdate?.(s)
241
+ },
242
+ onResultChange: (r: TOutput | null) => {
243
+ if (disposed) return
244
+ result = r
245
+ notify()
246
+ },
247
+ onLoadingChange: (l: boolean) => {
248
+ if (disposed) return
249
+ isLoading = l
250
+ notify()
251
+ },
252
+ onErrorChange: (e: Error | undefined) => {
253
+ if (disposed) return
254
+ error = e
255
+ notify()
256
+ },
257
+ onStatusChange: (s: GenerationClientState) => {
258
+ if (disposed) return
259
+ status = s
260
+ notify()
261
+ },
262
+ onJobIdChange: (id: string | null) => {
263
+ if (disposed) return
264
+ jobId = id
265
+ notify()
266
+ },
267
+ onVideoStatusChange: (s: VideoStatusInfo | null) => {
268
+ if (disposed) return
269
+ videoStatus = s
270
+ notify()
271
+ },
272
+ onResumeStateChange: (rs: { runId: string } | null) => {
273
+ if (disposed) return
274
+ runId = rs?.runId ?? null
275
+ notify()
276
+ },
277
+ }
278
+
279
+ const persistenceProps =
280
+ typeof options.threadId === 'string' && options.persistence
281
+ ? {
282
+ persistence: options.persistence,
283
+ threadId: options.threadId,
284
+ }
285
+ : {
286
+ threadId,
287
+ }
288
+
289
+ let client: VideoGenerationClient<TOutput>
290
+ if (options.connection) {
291
+ client = new VideoGenerationClient<TOutput>({
292
+ ...baseOptions,
293
+ ...persistenceProps,
294
+ connection: options.connection,
295
+ })
296
+ } else if (options.fetcher) {
297
+ client = new VideoGenerationClient<TOutput>({
298
+ ...baseOptions,
299
+ ...persistenceProps,
300
+ fetcher: options.fetcher,
301
+ })
302
+ } else {
303
+ throw new Error(
304
+ 'createGenerateVideo requires either a connection or fetcher option',
305
+ )
306
+ }
307
+
308
+ const dispose = () => {
309
+ if (disposed) return
310
+ disposed = true
311
+ client.dispose()
312
+ }
313
+
314
+ if (handle.signal.aborted) {
315
+ dispose()
316
+ } else {
317
+ client.mountDevtools()
318
+ handle.signal.addEventListener('abort', dispose, { once: true })
319
+ }
320
+
321
+ return {
322
+ get result() {
323
+ return result
324
+ },
325
+ get jobId() {
326
+ return jobId
327
+ },
328
+ get videoStatus() {
329
+ return videoStatus
330
+ },
331
+ get isLoading() {
332
+ return isLoading
333
+ },
334
+ get error() {
335
+ return error
336
+ },
337
+ get status() {
338
+ return status
339
+ },
340
+ generate: (input: VideoGenerateInput) => client.generate(input),
341
+ stop: () => client.stop(),
342
+ reset: () => client.reset(),
343
+ get runId() {
344
+ return runId
345
+ },
346
+ }
347
+ }