@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,106 @@
1
+ import { AudioRecorder } from '@tanstack/ai-client'
2
+ import type {
3
+ AudioRecorderOptions,
4
+ AudioRecording,
5
+ InferAudioRecordingOutput,
6
+ } from '@tanstack/ai-client'
7
+ import type { Handle } from 'remix/ui'
8
+
9
+ export type CreateAudioRecorderOptions<TOnComplete> = AudioRecorderOptions & {
10
+ /**
11
+ * Optional transform applied to the recording when `stop()` resolves. Its
12
+ * (awaited) return value becomes `recording` and the resolved value of
13
+ * `stop()`. Return nothing to keep the raw `AudioRecording`.
14
+ */
15
+ onComplete?: TOnComplete
16
+ }
17
+
18
+ /**
19
+ * Remix factory for recording an audio message. Call in setup with Handle.
20
+ * The resolved {@link AudioRecording} carries `.part` (an audio content part
21
+ * for `createChat.sendMessage`) and `.base64` (for generation helpers).
22
+ *
23
+ * Recorder state changes call `handle.update()`. Disconnect aborts
24
+ * `handle.signal`, which unsubscribes and cancels the recorder.
25
+ *
26
+ * Errors are delivered via `onError`. `start()` and `stop()` also reject on
27
+ * failure (and `stop()` rejects with `Recording cancelled` if the component
28
+ * disconnects while a stop is in flight) — handle one channel, not both.
29
+ *
30
+ * @param handle Remix component handle from setup. Re-renders on recorder
31
+ * state changes and cancels on disconnect.
32
+ * @param options Recorder options plus an optional `onComplete` transform.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * function Voice(handle: Handle) {
37
+ * const recorder = createAudioRecorder(handle)
38
+ * return () => (
39
+ * <button onClick={() => void recorder.start()}>
40
+ * {recorder.isRecording ? 'Recording' : 'Record'}
41
+ * </button>
42
+ * )
43
+ * }
44
+ * ```
45
+ */
46
+ // TOnComplete defaults to undefined so `{ onError }` does not infer
47
+ // `unknown` and collapse `recording` / `stop()` (issue #1001).
48
+ export function createAudioRecorder<
49
+ TOnComplete extends ((recording: AudioRecording) => unknown) | undefined =
50
+ undefined,
51
+ >(handle: Handle, options: CreateAudioRecorderOptions<TOnComplete> = {}) {
52
+ const recorder = new AudioRecorder({
53
+ ...(options.audio !== undefined && { audio: options.audio }),
54
+ ...(options.mimeType !== undefined && { mimeType: options.mimeType }),
55
+ onError: (error) => options.onError?.(error),
56
+ })
57
+
58
+ let isRecording = false
59
+ let recording: InferAudioRecordingOutput<TOnComplete> | null = null
60
+
61
+ const unsubscribe = recorder.subscribe((state) => {
62
+ isRecording = state === 'recording'
63
+ void handle.update()
64
+ })
65
+
66
+ const teardown = () => {
67
+ unsubscribe()
68
+ recorder.cancel()
69
+ }
70
+ handle.signal.addEventListener('abort', teardown, { once: true })
71
+ if (handle.signal.aborted) {
72
+ teardown()
73
+ }
74
+
75
+ return {
76
+ get recording() {
77
+ return recording
78
+ },
79
+ get isRecording() {
80
+ return isRecording
81
+ },
82
+ get isSupported() {
83
+ return AudioRecorder.isSupported()
84
+ },
85
+ start: () => recorder.start(),
86
+ async stop() {
87
+ const rawRecording = await recorder.stop()
88
+ if (handle.signal.aborted) {
89
+ throw new Error('Recording cancelled')
90
+ }
91
+ const transformed = await options.onComplete?.(rawRecording)
92
+ if (handle.signal.aborted) {
93
+ throw new Error('Recording cancelled')
94
+ }
95
+ // Only `undefined` (returning nothing) keeps the raw recording; a
96
+ // returned null is a real value, matching the inferred output type.
97
+ const output = (
98
+ transformed === undefined ? rawRecording : transformed
99
+ ) as InferAudioRecordingOutput<TOnComplete>
100
+ recording = output
101
+ void handle.update()
102
+ return output
103
+ },
104
+ cancel: () => recorder.cancel(),
105
+ }
106
+ }
@@ -0,0 +1,29 @@
1
+ import type { ByokClient } from '@tanstack/ai-client/byok'
2
+ import type { Handle } from 'remix/ui'
3
+
4
+ /**
5
+ * Subscribe to a BYOK snapshot in Remix setup.
6
+ *
7
+ * Call this from a component setup function. The returned getter reads the
8
+ * latest snapshot. When the client changes, the helper calls `handle.update()`
9
+ * so the component renders again. It unsubscribes when `handle.signal` aborts.
10
+ *
11
+ * @param handle Remix setup handle
12
+ * @param client BYOK keyring
13
+ */
14
+ export function createByok(
15
+ handle: Pick<Handle, 'update' | 'signal'>,
16
+ client: Pick<ByokClient, 'getSnapshot' | 'subscribe'>,
17
+ ) {
18
+ let snapshot = client.getSnapshot()
19
+ const unsubscribe = client.subscribe(() => {
20
+ snapshot = client.getSnapshot()
21
+ void handle.update()
22
+ })
23
+ if (handle.signal.aborted) {
24
+ unsubscribe()
25
+ } else {
26
+ handle.signal.addEventListener('abort', unsubscribe, { once: true })
27
+ }
28
+ return () => snapshot
29
+ }
@@ -0,0 +1,421 @@
1
+ import { ChatClient } from '@tanstack/ai-client'
2
+ import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools'
3
+ import type { Handle } from 'remix/ui'
4
+ import type {
5
+ ChatClientState,
6
+ ResolvableChatInterrupt,
7
+ ChatInterruptState,
8
+ ChatResumeState,
9
+ ConnectionStatus,
10
+ InferredClientContext,
11
+ QueuedMessage,
12
+ SendMessageOptions,
13
+ StructuredOutputPart,
14
+ } from '@tanstack/ai-client'
15
+ import type {
16
+ AnyClientTool,
17
+ InterruptDefinition,
18
+ InferSchemaType,
19
+ ModelMessage,
20
+ RunAgentResumeItem,
21
+ SchemaInput,
22
+ StreamChunk,
23
+ } from '@tanstack/ai/client'
24
+ import type {
25
+ CreateChatOptions,
26
+ CreateChatReturn,
27
+ DeepPartial,
28
+ MultimodalContent,
29
+ UIMessage,
30
+ } from './types.ts'
31
+
32
+ const EMPTY_INTERRUPTS = Object.freeze([])
33
+ const EMPTY_INTERRUPT_ERRORS = Object.freeze([])
34
+
35
+ /**
36
+ * Create a chat helper for a Remix component.
37
+ *
38
+ * Call this in setup with the component Handle from `remix/ui`. The helper
39
+ * wraps ChatClient and stores chat state in local variables. ChatClient state
40
+ * callbacks write those variables and then call `handle.update()`, so render
41
+ * reads the latest snapshot through getters.
42
+ *
43
+ * The default thread id is `options.threadId ?? handle.id`. Cleanup runs when
44
+ * `handle.signal` aborts. Do not pass identification in `options.devtools`;
45
+ * the helper sets `framework: 'remix'` and `hookName: 'createChat'`.
46
+ *
47
+ * @param handle Remix component handle from setup.
48
+ * @param options Chat client options. Pass `connection` or `fetcher`.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * import { createChat } from '@tanstack/ai-remix'
53
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
54
+ * import type { Handle } from 'remix/ui'
55
+ *
56
+ * function Chat(handle: Handle) {
57
+ * const chat = createChat(handle, {
58
+ * connection: fetchServerSentEvents('/api/chat'),
59
+ * })
60
+ * return () => (
61
+ * <div>
62
+ * {chat.messages.map((message) => (
63
+ * <div>{message.role}</div>
64
+ * ))}
65
+ * <button on={{ click: () => chat.sendMessage('Hello') }}>Send</button>
66
+ * </div>
67
+ * )
68
+ * }
69
+ * ```
70
+ *
71
+ * @see {@link CreateChatReturn}
72
+ */
73
+ export function createChat<
74
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
75
+ TSchema extends SchemaInput | undefined = undefined,
76
+ TContext = InferredClientContext<TTools>,
77
+ const TInterrupts extends ReadonlyArray<
78
+ InterruptDefinition<any, any, any, any>
79
+ > = readonly [],
80
+ >(
81
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
82
+ options: CreateChatOptions<TTools, TSchema, TContext, TInterrupts>,
83
+ ) {
84
+ let messages = options.initialMessages || []
85
+ let isLoading = false
86
+ let error: Error | undefined
87
+ let status: ChatClientState = 'ready'
88
+ let isSubscribed = false
89
+ let connectionStatus: ConnectionStatus = 'disconnected'
90
+ let sessionGenerating = false
91
+ let queue: Array<QueuedMessage> = []
92
+ let runId: string | null = null
93
+ let interruptState: ChatInterruptState<TTools, TInterrupts> = {
94
+ interrupts: EMPTY_INTERRUPTS,
95
+ pendingInterrupts: EMPTY_INTERRUPTS,
96
+ interruptErrors: EMPTY_INTERRUPT_ERRORS,
97
+ resuming: false,
98
+ }
99
+ let closed = false
100
+
101
+ type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
102
+ type Final = InferSchemaType<NonNullable<TSchema>>
103
+
104
+ const threadId = options.threadId ?? handle.id
105
+ const transport = options.connection
106
+ ? { connection: options.connection }
107
+ : { fetcher: options.fetcher }
108
+
109
+ function commit() {
110
+ if (closed) return
111
+ void handle.update()
112
+ }
113
+
114
+ const client = new ChatClient<TTools, TContext, TInterrupts>({
115
+ devtoolsBridgeFactory: createChatDevtoolsBridge,
116
+ ...transport,
117
+ ...(options.initialMessages !== undefined && {
118
+ initialMessages: options.initialMessages,
119
+ }),
120
+ ...(options.persistence
121
+ ? {
122
+ persistence: options.persistence,
123
+ threadId,
124
+ }
125
+ : { threadId }),
126
+ ...(options.initialResumeSnapshot !== undefined && {
127
+ initialResumeSnapshot: options.initialResumeSnapshot,
128
+ }),
129
+ ...(options.body !== undefined && { body: options.body }),
130
+ ...(options.forwardedProps !== undefined && {
131
+ forwardedProps: options.forwardedProps,
132
+ }),
133
+ ...(options.byok !== undefined && { byok: options.byok }),
134
+ byokProvider: () => options.byokProvider?.(),
135
+ ...(options.context !== undefined && { context: options.context }),
136
+ devtools: {
137
+ ...options.devtools,
138
+ framework: 'remix',
139
+ hookName: 'createChat',
140
+ outputKind: options.outputSchema ? 'structured' : 'chat',
141
+ },
142
+ onResponse: (response) => options.onResponse?.(response),
143
+ onChunk: (chunk: StreamChunk) => {
144
+ options.onChunk?.(chunk)
145
+ },
146
+ onFinish: (message) => {
147
+ options.onFinish?.(message)
148
+ },
149
+ onError: (err) => {
150
+ options.onError?.(err)
151
+ },
152
+ ...(options.tools !== undefined && { tools: options.tools }),
153
+ ...(options.interrupts !== undefined && {
154
+ interrupts: options.interrupts,
155
+ }),
156
+ onCustomEvent: (eventType, data, context) =>
157
+ options.onCustomEvent?.(eventType, data, context),
158
+ ...(options.streamProcessor !== undefined && {
159
+ streamProcessor: options.streamProcessor,
160
+ }),
161
+ onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
162
+ messages = newMessages
163
+ commit()
164
+ },
165
+ onLoadingChange: (newIsLoading: boolean) => {
166
+ isLoading = newIsLoading
167
+ syncResumeState()
168
+ commit()
169
+ },
170
+ onStatusChange: (newStatus: ChatClientState) => {
171
+ status = newStatus
172
+ commit()
173
+ },
174
+ onErrorChange: (newError: Error | undefined) => {
175
+ error = newError
176
+ commit()
177
+ },
178
+ onSubscriptionChange: (nextIsSubscribed: boolean) => {
179
+ isSubscribed = nextIsSubscribed
180
+ commit()
181
+ },
182
+ onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
183
+ connectionStatus = nextStatus
184
+ commit()
185
+ },
186
+ onSessionGeneratingChange: (isGenerating: boolean) => {
187
+ sessionGenerating = isGenerating
188
+ commit()
189
+ },
190
+ ...(options.queue !== undefined && { queue: options.queue }),
191
+ onQueueChange: (nextQueue: Array<QueuedMessage>) => {
192
+ queue = nextQueue
193
+ commit()
194
+ },
195
+ onRunIdChange: (nextRunId) => {
196
+ runId = nextRunId
197
+ commit()
198
+ },
199
+ onInterruptStateChange: (nextInterruptState, context) => {
200
+ interruptState = nextInterruptState
201
+ options.onInterruptStateChange?.(nextInterruptState, context)
202
+ commit()
203
+ },
204
+ })
205
+
206
+ function syncResumeState() {
207
+ runId = client.getCurrentRunId()
208
+ interruptState = client.getInterruptState()
209
+ }
210
+
211
+ messages = client.getMessages()
212
+ interruptState = client.getInterruptState()
213
+ runId = client.getCurrentRunId()
214
+
215
+ function close() {
216
+ if (closed) return
217
+ closed = true
218
+ client.detach()
219
+ if (options.live) {
220
+ client.unsubscribe()
221
+ } else {
222
+ client.stop()
223
+ }
224
+ client.dispose()
225
+ }
226
+
227
+ if (handle.signal.aborted) {
228
+ close()
229
+ } else {
230
+ handle.signal.addEventListener('abort', close, { once: true })
231
+ if (options.live) {
232
+ client.subscribe()
233
+ }
234
+ client.attach()
235
+ client.mountDevtools()
236
+ }
237
+
238
+ const sendMessage = async (
239
+ content: string | MultimodalContent,
240
+ sendOptions?: SendMessageOptions,
241
+ ) => {
242
+ try {
243
+ await client.sendMessage(content, undefined, sendOptions)
244
+ } finally {
245
+ syncResumeState()
246
+ }
247
+ }
248
+
249
+ const cancelQueued = (id: string) => client.cancelQueued(id)
250
+
251
+ const append = async (message: ModelMessage | UIMessage<TTools>) => {
252
+ try {
253
+ await client.append(message)
254
+ } finally {
255
+ syncResumeState()
256
+ }
257
+ }
258
+
259
+ const reload = async () => {
260
+ try {
261
+ await client.reload()
262
+ } finally {
263
+ syncResumeState()
264
+ }
265
+ }
266
+
267
+ const stop = () => {
268
+ client.stop()
269
+ }
270
+
271
+ const clear = () => {
272
+ client.clear()
273
+ syncResumeState()
274
+ }
275
+
276
+ const setMessages = (newMessages: Array<UIMessage<TTools>>) => {
277
+ client.setMessagesManually(newMessages)
278
+ }
279
+
280
+ const addToolResult = async (result: {
281
+ toolCallId: string
282
+ tool: string
283
+ output: any
284
+ state?: 'output-available' | 'output-error'
285
+ errorText?: string
286
+ }) => {
287
+ await client.addToolResult(result)
288
+ }
289
+
290
+ /** @deprecated Use a bound `tool-approval` interrupt and `interrupt.resolveInterrupt`. */
291
+ const addToolApprovalResponse = async (response: {
292
+ id: string
293
+ approved: boolean
294
+ }) => {
295
+ await client.addToolApprovalResponse(response)
296
+ syncResumeState()
297
+ }
298
+
299
+ const resumeInterrupts = async (
300
+ resumeItems: Array<RunAgentResumeItem>,
301
+ state?: ChatResumeState,
302
+ ) => {
303
+ const result = await client.resumeInterrupts(resumeItems, state)
304
+ syncResumeState()
305
+ return result
306
+ }
307
+
308
+ const resolveInterrupts = (
309
+ resolution:
310
+ | boolean
311
+ | ((
312
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
313
+ ) => undefined),
314
+ ) => {
315
+ if (typeof resolution === 'boolean') {
316
+ client.resolveInterrupts(resolution)
317
+ } else {
318
+ client.resolveInterrupts(resolution)
319
+ }
320
+ }
321
+
322
+ const cancelInterrupts = () => {
323
+ client.cancelInterrupts()
324
+ }
325
+
326
+ const retryInterrupts = () => {
327
+ client.retryInterrupts()
328
+ }
329
+
330
+ const resumeInterruptsUnsafe = (
331
+ resumeItems: Array<RunAgentResumeItem>,
332
+ state?: ChatResumeState,
333
+ ) => client.resumeInterruptsUnsafe(resumeItems, state)
334
+
335
+ function activeStructuredPart(): StructuredOutputPart | null {
336
+ let lastUserIndex = -1
337
+ for (let i = messages.length - 1; i >= 0; i--) {
338
+ if (messages[i]?.role === 'user') {
339
+ lastUserIndex = i
340
+ break
341
+ }
342
+ }
343
+ if (lastUserIndex === -1) return null
344
+ for (let i = messages.length - 1; i > lastUserIndex; i--) {
345
+ const m = messages[i]
346
+ if (m?.role !== 'assistant') continue
347
+ const part = m.parts.find(
348
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
349
+ )
350
+ if (part) return part
351
+ }
352
+ return null
353
+ }
354
+
355
+ return {
356
+ get messages() {
357
+ return messages
358
+ },
359
+ get isLoading() {
360
+ return isLoading
361
+ },
362
+ get error() {
363
+ return error
364
+ },
365
+ get status() {
366
+ return status
367
+ },
368
+ get isSubscribed() {
369
+ return isSubscribed
370
+ },
371
+ get connectionStatus() {
372
+ return connectionStatus
373
+ },
374
+ get sessionGenerating() {
375
+ return sessionGenerating
376
+ },
377
+ get queue() {
378
+ return queue
379
+ },
380
+ get runId() {
381
+ return runId
382
+ },
383
+ get interrupts() {
384
+ return interruptState.interrupts
385
+ },
386
+ get pendingInterrupts() {
387
+ return interruptState.interrupts
388
+ },
389
+ get interruptErrors() {
390
+ return interruptState.interruptErrors
391
+ },
392
+ get resuming() {
393
+ return interruptState.resuming
394
+ },
395
+ get partial() {
396
+ const part = activeStructuredPart()
397
+ if (!part) return {} as Partial
398
+ const v = part.partial ?? part.data
399
+ return (v ?? {}) as Partial
400
+ },
401
+ get final() {
402
+ const part = activeStructuredPart()
403
+ if (!part || part.status !== 'complete') return null
404
+ return part.data as Final
405
+ },
406
+ sendMessage,
407
+ cancelQueued,
408
+ append,
409
+ reload,
410
+ stop,
411
+ setMessages,
412
+ clear,
413
+ addToolResult,
414
+ addToolApprovalResponse,
415
+ resolveInterrupts,
416
+ cancelInterrupts,
417
+ retryInterrupts,
418
+ resumeInterruptsUnsafe,
419
+ resumeInterrupts,
420
+ }
421
+ }
@@ -0,0 +1,93 @@
1
+ import { createGeneration } from './create-generation.ts'
2
+ import { reconstructAudioResult } from '@tanstack/ai-client'
3
+ import type { Handle } from 'remix/ui'
4
+ import type { AudioGenerationResult } from '@tanstack/ai'
5
+ import type {
6
+ AudioGenerateInput,
7
+ GenerationPersistenceOptions,
8
+ } from '@tanstack/ai-client'
9
+ import type {
10
+ CreateGenerationOptions,
11
+ CreateGenerationReturn,
12
+ } from './create-generation.ts'
13
+
14
+ /**
15
+ * Options for the createGenerateAudio 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 AudioGenerationResult)
20
+ */
21
+ export type CreateGenerateAudioOptions<TOutput = AudioGenerationResult> = Omit<
22
+ CreateGenerationOptions<AudioGenerateInput, AudioGenerationResult, TOutput>,
23
+ 'onResult' | 'reconstructResult'
24
+ > & {
25
+ onResult?: (result: AudioGenerationResult) => TOutput | null | void
26
+ }
27
+
28
+ /**
29
+ * Return type for the createGenerateAudio helper.
30
+ *
31
+ * @template TOutput - The output type (after optional transform)
32
+ */
33
+ export type CreateGenerateAudioReturn<TOutput = AudioGenerationResult> =
34
+ CreateGenerationReturn<TOutput, AudioGenerateInput>
35
+
36
+ /**
37
+ * Creates an audio generation 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 { createGenerateAudio } from '@tanstack/ai-remix'
45
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
46
+ * import type { Handle } from 'remix/ui'
47
+ *
48
+ * function AudioGenerator(handle: Handle) {
49
+ * const audio = createGenerateAudio(handle, {
50
+ * connection: fetchServerSentEvents('/api/generate/audio'),
51
+ * })
52
+ *
53
+ * return () => (
54
+ * <div>
55
+ * <button
56
+ * onClick={() =>
57
+ * audio.generate({ prompt: 'An upbeat electronic track', duration: 10 })
58
+ * }
59
+ * >
60
+ * Generate
61
+ * </button>
62
+ * {audio.result?.audio.url ? (
63
+ * <audio src={audio.result.audio.url} controls />
64
+ * ) : null}
65
+ * </div>
66
+ * )
67
+ * }
68
+ * ```
69
+ */
70
+ export function createGenerateAudio<TTransformed = void>(
71
+ handle: Pick<Handle, 'id' | 'update' | 'signal'>,
72
+ options: Omit<
73
+ CreateGenerateAudioOptions,
74
+ 'onResult' | 'persistence' | 'threadId'
75
+ > & {
76
+ onResult?: (result: AudioGenerationResult) => TTransformed
77
+ } & GenerationPersistenceOptions,
78
+ ) {
79
+ const devtools = {
80
+ ...options.devtools,
81
+ hookName: 'createGenerateAudio',
82
+ outputKind: 'audio' as const,
83
+ }
84
+ return createGeneration<
85
+ AudioGenerateInput,
86
+ AudioGenerationResult,
87
+ TTransformed
88
+ >(handle, {
89
+ ...options,
90
+ devtools,
91
+ reconstructResult: reconstructAudioResult,
92
+ })
93
+ }