@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,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
|
+
}
|