@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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/package.json +71 -0
- package/src/chat-ui/chat-input.tsx +125 -0
- package/src/chat-ui/chat-message.tsx +197 -0
- package/src/chat-ui/chat-messages.tsx +67 -0
- package/src/chat-ui/chat.tsx +48 -0
- package/src/chat-ui/create-chat-hook.ts +66 -0
- package/src/chat-ui/create-ui.tsx +516 -0
- package/src/chat-ui/text-part.tsx +29 -0
- package/src/chat-ui/thinking-part.tsx +44 -0
- package/src/chat-ui/tool-approval.tsx +94 -0
- package/src/create-audio-recorder.ts +106 -0
- package/src/create-byok.ts +29 -0
- package/src/create-chat.ts +421 -0
- package/src/create-generate-audio.ts +93 -0
- package/src/create-generate-image.ts +94 -0
- package/src/create-generate-speech.ts +93 -0
- package/src/create-generate-video.ts +347 -0
- package/src/create-generation.ts +333 -0
- package/src/create-mcp-app-bridge.ts +36 -0
- package/src/create-realtime-chat.ts +242 -0
- package/src/create-summarize.ts +96 -0
- package/src/create-transcription.ts +105 -0
- package/src/index.ts +92 -0
- package/src/realtime-types.ts +150 -0
- package/src/types.ts +313 -0
- package/src/ui.ts +41 -0
|
@@ -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
|
+
}
|