@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,333 @@
1
+ import { GenerationClient } from '@tanstack/ai-client'
2
+ import { createGenerationDevtoolsBridge } 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
+ GenerationClientOptions,
9
+ GenerationClientState,
10
+ GenerationFetcher,
11
+ GenerationPersistenceOptions,
12
+ GenerationRestoredResult,
13
+ InferGenerationOutputFromReturn,
14
+ } from '@tanstack/ai-client'
15
+ import type { ByokClient } from '@tanstack/ai-client/byok'
16
+ import type { ProviderId } from '@tanstack/ai/byok'
17
+
18
+ type RemixHandle = Pick<Handle, 'id' | 'update' | 'signal'>
19
+
20
+ /**
21
+ * Options for the createGeneration helper.
22
+ *
23
+ * Accepts either a `connection` (streaming transport) or a `fetcher` (direct async call).
24
+ * Handle is the first argument of the helper. It is not part of this type.
25
+ *
26
+ * @template TInput - The input type for the generation request
27
+ * @template TResult - The result type returned by the generation
28
+ * @template TOutput - The output type after optional transform (defaults to TResult)
29
+ */
30
+ export interface CreateGenerationOptions<TInput, TResult, TOutput = TResult> {
31
+ /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */
32
+ connection?: ConnectConnectionAdapter
33
+ /** Direct async function for one-shot generation (no streaming protocol needed) */
34
+ fetcher?: GenerationFetcher<TInput, TResult>
35
+ /** Additional body parameters to send with connect-based adapter requests */
36
+ body?: Record<string, any>
37
+ /** Optional BYOK keyring. Keys go in `x-byok-*` headers, never the body. */
38
+ byok?: ByokClient
39
+ /** Optional provider id. If it returns a slug, only that key is sent. If no slug resolves (`byokProvider`, then `body.provider`), generate throws. */
40
+ byokProvider?: () => ProviderId | undefined
41
+ /** Display options for TanStack AI Devtools. */
42
+ devtools?: AIDevtoolsDisplayOptions
43
+ /**
44
+ * How this generation persists across reloads.
45
+ * - Omit / `false`: ephemeral, in-memory only.
46
+ * - `true`: server-driven — on mount the client hydrates the last generation
47
+ * for its `threadId` from the server (needs a connection with a
48
+ * `hydrateGeneration` handler) and repaints it; it never auto-starts a run.
49
+ */
50
+ persistence?: boolean
51
+ /**
52
+ * The **scope** this generation belongs to: a stable, app-chosen name for the
53
+ * slot successive runs fill — not a link to a chat conversation.
54
+ *
55
+ * The helper starts empty and produces many runs over its life; each gets its
56
+ * own `runId`, but all belong to one scope. Persistence keys on this, so
57
+ * derive it from your own domain and keep it identical across reloads (e.g.
58
+ * `` `video-${videoId}-start-frame` ``). It is also sent as the AG-UI thread
59
+ * id on the wire, which the protocol requires.
60
+ *
61
+ * **Required whenever `persistence` is set** — an app that cannot name the
62
+ * scope has nothing to restore to. Optional for ephemeral generations. If
63
+ * omitted, the helper uses `handle.id`.
64
+ */
65
+ threadId?: string
66
+ /**
67
+ * Server-driven hydration handler for `persistence: true` when the
68
+ * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` /
69
+ * `rpcStream()` adapter built without handlers) — typically a one-line
70
+ * server-function call. The connection's own handler takes precedence.
71
+ */
72
+ hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration']
73
+ /**
74
+ * Re-attach handler that replays a run still generating to completion on
75
+ * mount, when the connection doesn't carry one. Without it, a restored
76
+ * `running` snapshot surfaces as an (interrupted) error. The connection's
77
+ * own handler takes precedence.
78
+ */
79
+ joinRun?: ConnectConnectionAdapter['joinRun']
80
+ /**
81
+ * Callback when a result is received. Can optionally return a transformed value.
82
+ *
83
+ * - Return a non-null value to transform and store it as the result
84
+ * - Return `null` to keep the previous result unchanged
85
+ * - Return nothing (`void`) to store the raw result as-is
86
+ */
87
+ onResult?: (result: TResult) => TOutput | null | void
88
+ /** Callback when an error occurs */
89
+ onError?: (error: Error) => void
90
+ /** Callback when progress is reported (0-100) */
91
+ onProgress?: (progress: number, message?: string) => void
92
+ /** Callback for each stream chunk (connect-based adapter mode only) */
93
+ onChunk?: (chunk: StreamChunk) => void
94
+ /**
95
+ * @internal Rebuild a typed result from a restored snapshot, injected by each
96
+ * specialized helper (image / speech / audio / transcription / summarize).
97
+ * Forwarded to the client so a server-hydrate restore repaints `result`.
98
+ */
99
+ reconstructResult?: (restored: GenerationRestoredResult) => TResult | null
100
+ }
101
+
102
+ /**
103
+ * Return type for the createGeneration helper.
104
+ *
105
+ * Fields are getters over local lets. Remix re-renders read the latest values
106
+ * after `handle.update()`.
107
+ *
108
+ * @template TOutput - The output type (after optional transform)
109
+ * @template TInput - The input type accepted by `generate` (defaults to any object)
110
+ */
111
+ export interface CreateGenerationReturn<
112
+ TOutput,
113
+ TInput extends Record<string, any> = Record<string, any>,
114
+ > {
115
+ /** The generation result, or null if not yet generated */
116
+ readonly result: TOutput | null
117
+ /** Whether a generation is currently in progress */
118
+ readonly isLoading: boolean
119
+ /** Current error, if any */
120
+ readonly error: Error | undefined
121
+ /** Current state of the generation client */
122
+ readonly status: GenerationClientState
123
+ /** Trigger a generation request */
124
+ generate: (input: TInput) => Promise<void>
125
+ /** Abort the current generation */
126
+ stop: () => void
127
+ /** Clear result, error, and return to idle */
128
+ reset: () => void
129
+ /**
130
+ * The id of the generation job currently running, or `null` when nothing is in
131
+ * flight. Each call to `generate` is one job with its own id. Pass it to your
132
+ * own endpoint to cancel or poll the provider job — `stop()` only aborts the
133
+ * local stream, it does not stop work already running on the provider.
134
+ */
135
+ readonly runId: string | null
136
+ }
137
+
138
+ /**
139
+ * Creates a generation helper for Remix setup.
140
+ *
141
+ * This is the base helper used by `createGenerateImage`, `createGenerateSpeech`,
142
+ * `createTranscription`, and `createSummarize`. You can also use it for custom
143
+ * generation types.
144
+ *
145
+ * Call this in a Remix component setup function. Pass the component Handle as
146
+ * the first argument. Pass either `connection` or `fetcher`.
147
+ *
148
+ * @example
149
+ * ```tsx
150
+ * import { createGeneration } from '@tanstack/ai-remix'
151
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
152
+ * import type { Handle } from 'remix/ui'
153
+ *
154
+ * function CustomGenerator(handle: Handle) {
155
+ * const gen = createGeneration(handle, {
156
+ * connection: fetchServerSentEvents('/api/generate/custom'),
157
+ * })
158
+ *
159
+ * return () => (
160
+ * <div>
161
+ * <button onClick={() => gen.generate({ prompt: 'Hello' })}>
162
+ * Generate
163
+ * </button>
164
+ * {gen.isLoading ? <p>Generating...</p> : null}
165
+ * </div>
166
+ * )
167
+ * }
168
+ * ```
169
+ */
170
+ // `TTransformed` infers from the `onResult` return position (a covariant
171
+ // inference site that works even for an optional nested property), which types
172
+ // the callback parameter as `TResult` and narrows `result`. Inferring the
173
+ // whole callback as a defaulted type parameter instead collapses to the
174
+ // default, leaving the parameter `any` — a hard error under `strict`. See
175
+ // issue #848.
176
+ export function createGeneration<
177
+ TInput extends Record<string, any>,
178
+ TResult,
179
+ TTransformed = void,
180
+ >(
181
+ handle: RemixHandle,
182
+ options: Omit<
183
+ CreateGenerationOptions<TInput, TResult>,
184
+ 'onResult' | 'persistence' | 'threadId'
185
+ > & {
186
+ onResult?: (result: TResult) => TTransformed
187
+ } & GenerationPersistenceOptions,
188
+ ) {
189
+ type TOutput = InferGenerationOutputFromReturn<TResult, TTransformed>
190
+
191
+ let result: TOutput | null = null
192
+ let isLoading = false
193
+ let error: Error | undefined = undefined
194
+ let status: GenerationClientState = 'idle'
195
+ let runId: string | null = null
196
+ let disposed = false
197
+
198
+ const notify = () => {
199
+ if (disposed) return
200
+ void handle.update()
201
+ }
202
+
203
+ const threadId = options.threadId ?? handle.id
204
+
205
+ // Conditional spread for `body` (strict-optional in target;
206
+ // local source is `Record<string, any> | undefined`). Callbacks
207
+ // wrap optional ones in non-returning bodies so `?.()`'s
208
+ // implicit `undefined` doesn't pollute the function return type.
209
+ const clientOptions: Omit<
210
+ GenerationClientOptions<TInput, TResult, TOutput>,
211
+ 'persistence' | 'threadId'
212
+ > = {
213
+ ...(options.body !== undefined && { body: options.body }),
214
+ ...(options.hydrateGeneration !== undefined && {
215
+ hydrateGeneration: options.hydrateGeneration,
216
+ }),
217
+ ...(options.joinRun !== undefined && { joinRun: options.joinRun }),
218
+ ...(options.byok !== undefined && { byok: options.byok }),
219
+ byokProvider: () => options.byokProvider?.(),
220
+ ...(options.reconstructResult
221
+ ? { reconstructResult: options.reconstructResult }
222
+ : {}),
223
+ devtoolsBridgeFactory: createGenerationDevtoolsBridge,
224
+ devtools: {
225
+ hookName: 'createGeneration',
226
+ ...options.devtools,
227
+ framework: 'remix',
228
+ },
229
+ // The transform's raw return type (`TTransformed`) and the stored output
230
+ // (`TOutput`, with null/void/undefined stripped) are identical at runtime;
231
+ // the cast bridges the relationship that the conditional type hides.
232
+ onResult: ((r: TResult) => options.onResult?.(r)) as (
233
+ result: TResult,
234
+ ) => TOutput | null | void,
235
+ onError: (e: Error) => {
236
+ if (!disposed) options.onError?.(e)
237
+ },
238
+ onProgress: (p: number, m?: string) => {
239
+ if (!disposed) options.onProgress?.(p, m)
240
+ },
241
+ onChunk: (c: StreamChunk) => {
242
+ if (!disposed) options.onChunk?.(c)
243
+ },
244
+ onResultChange: (r) => {
245
+ if (disposed) return
246
+ result = r
247
+ notify()
248
+ },
249
+ onLoadingChange: (l) => {
250
+ if (disposed) return
251
+ isLoading = l
252
+ notify()
253
+ },
254
+ onErrorChange: (e) => {
255
+ if (disposed) return
256
+ error = e
257
+ notify()
258
+ },
259
+ onStatusChange: (s) => {
260
+ if (disposed) return
261
+ status = s
262
+ notify()
263
+ },
264
+ onResumeStateChange: (rs) => {
265
+ if (disposed) return
266
+ runId = rs?.runId ?? null
267
+ notify()
268
+ },
269
+ }
270
+
271
+ const persistenceProps =
272
+ typeof options.threadId === 'string' && options.persistence
273
+ ? {
274
+ persistence: options.persistence,
275
+ threadId: options.threadId,
276
+ }
277
+ : {
278
+ threadId,
279
+ }
280
+
281
+ let client: GenerationClient<TInput, TResult, TOutput>
282
+ if (options.connection) {
283
+ client = new GenerationClient<TInput, TResult, TOutput>({
284
+ ...clientOptions,
285
+ ...persistenceProps,
286
+ connection: options.connection,
287
+ })
288
+ } else if (options.fetcher) {
289
+ client = new GenerationClient<TInput, TResult, TOutput>({
290
+ ...clientOptions,
291
+ ...persistenceProps,
292
+ fetcher: options.fetcher,
293
+ })
294
+ } else {
295
+ throw new Error(
296
+ 'createGeneration requires either a connection or fetcher option',
297
+ )
298
+ }
299
+
300
+ const dispose = () => {
301
+ if (disposed) return
302
+ disposed = true
303
+ client.dispose()
304
+ }
305
+
306
+ if (handle.signal.aborted) {
307
+ dispose()
308
+ } else {
309
+ client.mountDevtools()
310
+ handle.signal.addEventListener('abort', dispose, { once: true })
311
+ }
312
+
313
+ return {
314
+ get result() {
315
+ return result
316
+ },
317
+ get isLoading() {
318
+ return isLoading
319
+ },
320
+ get error() {
321
+ return error
322
+ },
323
+ get status() {
324
+ return status
325
+ },
326
+ generate: (input: TInput) => client.generate(input),
327
+ stop: () => client.stop(),
328
+ reset: () => client.reset(),
329
+ get runId() {
330
+ return runId
331
+ },
332
+ }
333
+ }
@@ -0,0 +1,36 @@
1
+ import { createMcpAppBridge as createClientMcpAppBridge } from '@tanstack/ai-client'
2
+ import type { CreateMcpAppBridgeOptions } from '@tanstack/ai-client'
3
+ import type { Handle } from 'remix/ui'
4
+
5
+ export type { CreateMcpAppBridgeOptions }
6
+
7
+ /**
8
+ * Remix setup wrapper around the client `createMcpAppBridge`.
9
+ *
10
+ * Call once in component setup with the Remix `Handle`. Setup does not re-run,
11
+ * so the bridge is created once from `options`. The client bridge has no
12
+ * dispose, so `handle.signal` is unused.
13
+ *
14
+ * @param handle Remix component handle from setup.
15
+ * @param options Same options as the client factory (`threadId`, `callEndpoint`,
16
+ * `chat.sendMessage`, optional `fetchImpl` / `onLink`).
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * function Widget(handle: Handle) {
21
+ * const bridge = createMcpAppBridge(handle, {
22
+ * threadId: 't1',
23
+ * callEndpoint: '/api/mcp-apps-call',
24
+ * chat: { sendMessage },
25
+ * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
26
+ * })
27
+ * return () => <MCPAppResource bridge={bridge} />
28
+ * }
29
+ * ```
30
+ */
31
+ export function createMcpAppBridge(
32
+ handle: Handle,
33
+ options: CreateMcpAppBridgeOptions,
34
+ ) {
35
+ return createClientMcpAppBridge(options)
36
+ }
@@ -0,0 +1,242 @@
1
+ import { RealtimeClient } from '@tanstack/ai-client'
2
+ import type {
3
+ RealtimeMessage,
4
+ RealtimeMode,
5
+ RealtimeSessionConfig,
6
+ RealtimeStatus,
7
+ } from '@tanstack/ai'
8
+ import type { Handle } from 'remix/ui'
9
+ import type { CreateRealtimeChatOptions } from './realtime-types.ts'
10
+
11
+ const emptyFrequencyData = new Uint8Array(128)
12
+ const emptyTimeDomainData = new Uint8Array(128).fill(128)
13
+
14
+ /**
15
+ * Remix helper for realtime voice conversations.
16
+ *
17
+ * Call from component setup with Handle. State fields are getters so the
18
+ * render function reads the current value after `handle.update()`.
19
+ *
20
+ * @param handle - Remix Handle from setup. Used to re-render and to clean up
21
+ * when the component disconnects.
22
+ * @param options - Adapter, token loader, and optional session/callback config.
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { createRealtimeChat } from '@tanstack/ai-remix'
27
+ * import { openaiRealtime } from '@tanstack/ai-openai'
28
+ * import type { Handle } from 'remix/ui'
29
+ *
30
+ * function VoiceChat(handle: Handle) {
31
+ * const chat = createRealtimeChat(handle, {
32
+ * getToken: () => fetch('/api/realtime-token').then((r) => r.json()),
33
+ * adapter: openaiRealtime(),
34
+ * })
35
+ *
36
+ * return () => (
37
+ * <div>
38
+ * <p>Status: {chat.status}</p>
39
+ * <button on={{ click: chat.status === 'idle' ? chat.connect : chat.disconnect }}>
40
+ * {chat.status === 'idle' ? 'Start' : 'Stop'}
41
+ * </button>
42
+ * </div>
43
+ * )
44
+ * }
45
+ * ```
46
+ */
47
+ export function createRealtimeChat(
48
+ handle: Handle,
49
+ options: CreateRealtimeChatOptions,
50
+ ) {
51
+ let status: RealtimeStatus = 'idle'
52
+ let mode: RealtimeMode = 'idle'
53
+ let messages: Array<RealtimeMessage> = []
54
+ let pendingUserTranscript: string | null = null
55
+ let pendingAssistantTranscript: string | null = null
56
+ let error: Error | null = null
57
+ let animationFrame: number | null = null
58
+
59
+ function notify() {
60
+ if (!handle.signal.aborted) {
61
+ void handle.update()
62
+ }
63
+ }
64
+
65
+ function stopLevelLoop() {
66
+ if (animationFrame === null) return
67
+ cancelAnimationFrame(animationFrame)
68
+ animationFrame = null
69
+ }
70
+
71
+ function startLevelLoop() {
72
+ if (animationFrame !== null) return
73
+ function tick() {
74
+ animationFrame = requestAnimationFrame(tick)
75
+ notify()
76
+ }
77
+ animationFrame = requestAnimationFrame(tick)
78
+ }
79
+
80
+ // Each optional source field is spread conditionally because the
81
+ // `RealtimeClientOptions` target declares strict optionals
82
+ // (`field?: T`) and `exactOptionalPropertyTypes` rejects passing
83
+ // `undefined` for absent values.
84
+ const client = new RealtimeClient({
85
+ getToken: () => options.getToken(),
86
+ adapter: {
87
+ get provider() {
88
+ return options.adapter.provider
89
+ },
90
+ connect(token, tools) {
91
+ return options.adapter.connect(token, tools)
92
+ },
93
+ },
94
+ ...(options.tools !== undefined && { tools: options.tools }),
95
+ ...(options.instructions !== undefined && {
96
+ instructions: options.instructions,
97
+ }),
98
+ ...(options.voice !== undefined && { voice: options.voice }),
99
+ ...(options.autoPlayback !== undefined && {
100
+ autoPlayback: options.autoPlayback,
101
+ }),
102
+ ...(options.autoCapture !== undefined && {
103
+ autoCapture: options.autoCapture,
104
+ }),
105
+ ...(options.vadMode !== undefined && { vadMode: options.vadMode }),
106
+ ...(options.outputModalities !== undefined && {
107
+ outputModalities: options.outputModalities,
108
+ }),
109
+ ...(options.temperature !== undefined && {
110
+ temperature: options.temperature,
111
+ }),
112
+ ...(options.maxOutputTokens !== undefined && {
113
+ maxOutputTokens: options.maxOutputTokens,
114
+ }),
115
+ ...(options.semanticEagerness !== undefined && {
116
+ semanticEagerness: options.semanticEagerness,
117
+ }),
118
+ onStatusChange: (newStatus) => {
119
+ status = newStatus
120
+ if (newStatus === 'connected') {
121
+ startLevelLoop()
122
+ } else {
123
+ stopLevelLoop()
124
+ }
125
+ notify()
126
+ options.onStatusChange?.(newStatus)
127
+ },
128
+ onModeChange: (newMode) => {
129
+ mode = newMode
130
+ notify()
131
+ options.onModeChange?.(newMode)
132
+ },
133
+ onMessage: (message) => {
134
+ messages = [...messages, message]
135
+ notify()
136
+ options.onMessage?.(message)
137
+ },
138
+ onUsage: (usage) => {
139
+ options.onUsage?.(usage)
140
+ },
141
+ onGoAway: (timeLeft) => {
142
+ options.onGoAway?.(timeLeft)
143
+ },
144
+ onError: (err) => {
145
+ error = err
146
+ notify()
147
+ options.onError?.(err)
148
+ },
149
+ onConnect: () => {
150
+ error = null
151
+ notify()
152
+ options.onConnect?.()
153
+ },
154
+ onDisconnect: () => {
155
+ options.onDisconnect?.()
156
+ },
157
+ onInterrupted: () => {
158
+ pendingAssistantTranscript = null
159
+ notify()
160
+ options.onInterrupted?.()
161
+ },
162
+ })
163
+
164
+ client.onStateChange((state) => {
165
+ pendingUserTranscript = state.pendingUserTranscript
166
+ pendingAssistantTranscript = state.pendingAssistantTranscript
167
+ notify()
168
+ })
169
+
170
+ handle.signal.addEventListener('abort', () => {
171
+ stopLevelLoop()
172
+ client.destroy()
173
+ })
174
+
175
+ return {
176
+ get status() {
177
+ return status
178
+ },
179
+ get error() {
180
+ return error
181
+ },
182
+ connect: async () => {
183
+ error = null
184
+ messages = []
185
+ pendingUserTranscript = null
186
+ pendingAssistantTranscript = null
187
+ notify()
188
+ await client.connect()
189
+ },
190
+ disconnect: () => client.disconnect(),
191
+
192
+ get mode() {
193
+ return mode
194
+ },
195
+ get messages() {
196
+ return messages
197
+ },
198
+ get pendingUserTranscript() {
199
+ return pendingUserTranscript
200
+ },
201
+ get pendingAssistantTranscript() {
202
+ return pendingAssistantTranscript
203
+ },
204
+
205
+ startListening: () => {
206
+ client.startListening()
207
+ },
208
+ stopListening: () => {
209
+ client.stopListening()
210
+ },
211
+ interrupt: () => {
212
+ client.interrupt()
213
+ },
214
+
215
+ sendText: (text: string) => {
216
+ client.sendText(text)
217
+ },
218
+
219
+ sendImage: (imageData: string, mimeType: string) => {
220
+ client.sendImage(imageData, mimeType)
221
+ },
222
+
223
+ get inputLevel() {
224
+ return client.audio?.inputLevel ?? 0
225
+ },
226
+ get outputLevel() {
227
+ return client.audio?.outputLevel ?? 0
228
+ },
229
+ getInputFrequencyData: () =>
230
+ client.audio?.getInputFrequencyData() ?? emptyFrequencyData,
231
+ getOutputFrequencyData: () =>
232
+ client.audio?.getOutputFrequencyData() ?? emptyFrequencyData,
233
+ getInputTimeDomainData: () =>
234
+ client.audio?.getInputTimeDomainData() ?? emptyTimeDomainData,
235
+ getOutputTimeDomainData: () =>
236
+ client.audio?.getOutputTimeDomainData() ?? emptyTimeDomainData,
237
+
238
+ updateSession: (config: RealtimeSessionConfig) => {
239
+ client.updateSession(config)
240
+ },
241
+ }
242
+ }
@@ -0,0 +1,96 @@
1
+ import { createGeneration } from './create-generation.ts'
2
+ import { reconstructSummarizeResult } from '@tanstack/ai-client'
3
+ import type { Handle } from 'remix/ui'
4
+ import type { SummarizationResult } from '@tanstack/ai'
5
+ import type {
6
+ GenerationPersistenceOptions,
7
+ SummarizeGenerateInput,
8
+ } from '@tanstack/ai-client'
9
+ import type {
10
+ CreateGenerationOptions,
11
+ CreateGenerationReturn,
12
+ } from './create-generation.ts'
13
+
14
+ /**
15
+ * Options for the createSummarize 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 SummarizationResult)
20
+ */
21
+ export type CreateSummarizeOptions<TOutput = SummarizationResult> = Omit<
22
+ CreateGenerationOptions<SummarizeGenerateInput, SummarizationResult, TOutput>,
23
+ 'onResult' | 'reconstructResult'
24
+ > & {
25
+ onResult?: (result: SummarizationResult) => TOutput | null | void
26
+ }
27
+
28
+ /**
29
+ * Return type for the createSummarize helper.
30
+ *
31
+ * @template TOutput - The output type (after optional transform)
32
+ */
33
+ export type CreateSummarizeReturn<TOutput = SummarizationResult> =
34
+ CreateGenerationReturn<TOutput, SummarizeGenerateInput>
35
+
36
+ /**
37
+ * Creates a text summarization 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 { createSummarize } from '@tanstack/ai-remix'
45
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
46
+ * import type { Handle } from 'remix/ui'
47
+ *
48
+ * function Summarizer(handle: Handle) {
49
+ * const summarizer = createSummarize(handle, {
50
+ * connection: fetchServerSentEvents('/api/summarize'),
51
+ * })
52
+ *
53
+ * return () => (
54
+ * <div>
55
+ * <button
56
+ * onClick={() =>
57
+ * summarizer.generate({
58
+ * text: 'Long article text...',
59
+ * style: 'bullet-points',
60
+ * maxLength: 200,
61
+ * })
62
+ * }
63
+ * >
64
+ * Summarize
65
+ * </button>
66
+ * {summarizer.isLoading ? <p>Summarizing...</p> : null}
67
+ * {summarizer.result ? <p>{summarizer.result.summary}</p> : null}
68
+ * </div>
69
+ * )
70
+ * }
71
+ * ```
72
+ */
73
+ export function createSummarize<TTransformed = void>(
74
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
75
+ options: Omit<
76
+ CreateSummarizeOptions,
77
+ 'onResult' | 'persistence' | 'threadId'
78
+ > & {
79
+ onResult?: (result: SummarizationResult) => TTransformed
80
+ } & GenerationPersistenceOptions,
81
+ ) {
82
+ const devtools = {
83
+ ...options.devtools,
84
+ hookName: 'createSummarize',
85
+ outputKind: 'text' as const,
86
+ }
87
+ return createGeneration<
88
+ SummarizeGenerateInput,
89
+ SummarizationResult,
90
+ TTransformed
91
+ >(handle, {
92
+ ...options,
93
+ devtools,
94
+ reconstructResult: reconstructSummarizeResult,
95
+ })
96
+ }