@octanejs/tanstack-ai 0.0.1

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,112 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'octane'
2
+ import { AudioRecorder } from '@tanstack/ai-client'
3
+ import type {
4
+ AudioRecorderOptions,
5
+ AudioRecording,
6
+ InferAudioRecordingOutput,
7
+ } from '@tanstack/ai-client'
8
+
9
+ export type UseAudioRecorderOptions<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
+ export interface UseAudioRecorderReturn<TOutput> {
19
+ /** Latest recording (transformed if `onComplete` provided), or null. */
20
+ recording: TOutput | null
21
+ /** True while actively capturing audio. */
22
+ isRecording: boolean
23
+ /** Whether the browser supports recording (getUserMedia + MediaRecorder). */
24
+ isSupported: boolean
25
+ /** Acquire the mic and begin recording. */
26
+ start: () => Promise<void>
27
+ /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */
28
+ stop: () => Promise<TOutput>
29
+ /** Discard the in-progress recording and release the mic. */
30
+ cancel: () => void
31
+ }
32
+
33
+ /**
34
+ * Octane hook for recording an audio message. The resolved
35
+ * {@link AudioRecording} carries `.part` (an audio content part for
36
+ * `useChat.sendMessage`) and `.base64` (for the generation hooks).
37
+ *
38
+ * Errors are delivered via `onError`. `start()` and `stop()` also reject on
39
+ * failure (and `stop()` rejects with `Recording cancelled` if the component
40
+ * unmounts while a stop is in flight) — handle one channel, not both.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const { isRecording, start, stop, recording } = useAudioRecorder()
45
+ * const { sendMessage } = useChat({ connection })
46
+ * // ...
47
+ * const rec = await stop()
48
+ * sendMessage({ content: [rec.part] })
49
+ * ```
50
+ */
51
+ export function useAudioRecorder<
52
+ TOnComplete extends (recording: AudioRecording) => unknown,
53
+ >(
54
+ options: UseAudioRecorderOptions<TOnComplete>,
55
+ ): UseAudioRecorderReturn<InferAudioRecordingOutput<TOnComplete>>
56
+ export function useAudioRecorder(
57
+ options?: UseAudioRecorderOptions<undefined>,
58
+ ): UseAudioRecorderReturn<AudioRecording>
59
+ export function useAudioRecorder(
60
+ options: UseAudioRecorderOptions<(recording: AudioRecording) => unknown> = {},
61
+ ): UseAudioRecorderReturn<unknown> {
62
+ const [isRecording, setIsRecording] = useState(false)
63
+ const [recording, setRecording] = useState<unknown>(null)
64
+ // Read the freshest callbacks at fire time without recreating the recorder.
65
+ const optionsRef = useRef(options)
66
+ optionsRef.current = options
67
+
68
+ const recorder = useMemo(
69
+ () =>
70
+ new AudioRecorder({
71
+ ...(options.audio !== undefined && { audio: options.audio }),
72
+ ...(options.mimeType !== undefined && { mimeType: options.mimeType }),
73
+ onError: (err) => optionsRef.current.onError?.(err),
74
+ }),
75
+ // Recorder config (audio/mimeType) is captured once at mount, matching the
76
+ // other hooks' create-once pattern.
77
+ [],
78
+ )
79
+
80
+ useEffect(() => {
81
+ const unsubscribe = recorder.subscribe((state) => {
82
+ setIsRecording(state === 'recording')
83
+ })
84
+ return () => {
85
+ unsubscribe()
86
+ recorder.cancel()
87
+ }
88
+ }, [recorder])
89
+
90
+ const start = useCallback(() => recorder.start(), [recorder])
91
+ const stop = useCallback(async () => {
92
+ const recording = await recorder.stop()
93
+ const transformed = await optionsRef.current.onComplete?.(recording)
94
+ // Only `undefined` (returning nothing) falls back to the raw recording, so
95
+ // a transform that returns null is preserved — matching the inferred type,
96
+ // which excludes only undefined/void/null from the transform's return.
97
+ const output = transformed === undefined ? recording : transformed
98
+ setRecording(() => output)
99
+ return output
100
+ }, [recorder])
101
+ const cancel = useCallback(() => recorder.cancel(), [recorder])
102
+
103
+ return {
104
+ recording,
105
+ isRecording,
106
+ // recording is client-only; if SSR'd, gate UI on a mounted flag.
107
+ isSupported: AudioRecorder.isSupported(),
108
+ start,
109
+ stop,
110
+ cancel,
111
+ }
112
+ }
@@ -0,0 +1,44 @@
1
+ // Declaration companion generated from use-audio-recorder.tsrx.
2
+ import type { AudioRecorderOptions, AudioRecording, InferAudioRecordingOutput } from '@tanstack/ai-client';
3
+ export type UseAudioRecorderOptions<TOnComplete> = AudioRecorderOptions & {
4
+ /**
5
+ * Optional transform applied to the recording when `stop()` resolves. Its
6
+ * (awaited) return value becomes `recording` and the resolved value of
7
+ * `stop()`. Return nothing to keep the raw `AudioRecording`.
8
+ */
9
+ onComplete?: TOnComplete;
10
+ };
11
+ export interface UseAudioRecorderReturn<TOutput> {
12
+ /** Latest recording (transformed if `onComplete` provided), or null. */
13
+ recording: TOutput | null;
14
+ /** True while actively capturing audio. */
15
+ isRecording: boolean;
16
+ /** Whether the browser supports recording (getUserMedia + MediaRecorder). */
17
+ isSupported: boolean;
18
+ /** Acquire the mic and begin recording. */
19
+ start: () => Promise<void>;
20
+ /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */
21
+ stop: () => Promise<TOutput>;
22
+ /** Discard the in-progress recording and release the mic. */
23
+ cancel: () => void;
24
+ }
25
+ /**
26
+ * Octane hook for recording an audio message. The resolved
27
+ * {@link AudioRecording} carries `.part` (an audio content part for
28
+ * `useChat.sendMessage`) and `.base64` (for the generation hooks).
29
+ *
30
+ * Errors are delivered via `onError`. `start()` and `stop()` also reject on
31
+ * failure (and `stop()` rejects with `Recording cancelled` if the component
32
+ * unmounts while a stop is in flight) — handle one channel, not both.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * const { isRecording, start, stop, recording } = useAudioRecorder()
37
+ * const { sendMessage } = useChat({ connection })
38
+ * // ...
39
+ * const rec = await stop()
40
+ * sendMessage({ content: [rec.part] })
41
+ * ```
42
+ */
43
+ export declare function useAudioRecorder<TOnComplete extends (recording: AudioRecording) => unknown>(options: UseAudioRecorderOptions<TOnComplete>): UseAudioRecorderReturn<InferAudioRecordingOutput<TOnComplete>>;
44
+ export declare function useAudioRecorder(options?: UseAudioRecorderOptions<undefined>): UseAudioRecorderReturn<AudioRecording>;
@@ -0,0 +1,363 @@
1
+ import { ChatClient } from '@tanstack/ai-client'
2
+ import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools'
3
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'octane'
4
+ import type {
5
+ AnyClientTool,
6
+ InferSchemaType,
7
+ ModelMessage,
8
+ SchemaInput,
9
+ StreamChunk,
10
+ } from '@tanstack/ai/client'
11
+ import type {
12
+ ChatClientState,
13
+ ConnectionStatus,
14
+ InferredClientContext,
15
+ StructuredOutputPart,
16
+ } from '@tanstack/ai-client'
17
+
18
+ import type {
19
+ DeepPartial,
20
+ MultimodalContent,
21
+ UIMessage,
22
+ UseChatOptions,
23
+ UseChatReturn,
24
+ } from './types'
25
+
26
+ export function useChat<
27
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
28
+ TSchema extends SchemaInput | undefined = undefined,
29
+ TContext = InferredClientContext<TTools>,
30
+ >(
31
+ options: UseChatOptions<TTools, TSchema, TContext>,
32
+ ): UseChatReturn<TTools, TSchema> {
33
+ const hookId = useId()
34
+ const clientId = options.id || hookId
35
+
36
+ const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(
37
+ options.initialMessages || [],
38
+ )
39
+ const [isLoading, setIsLoading] = useState(false)
40
+ const [error, setError] = useState<Error | undefined>(undefined)
41
+ const [status, setStatus] = useState<ChatClientState>('ready')
42
+ const [isSubscribed, setIsSubscribed] = useState(false)
43
+ const [connectionStatus, setConnectionStatus] =
44
+ useState<ConnectionStatus>('disconnected')
45
+ const [sessionGenerating, setSessionGenerating] = useState(false)
46
+
47
+ type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
48
+ type Final = InferSchemaType<NonNullable<TSchema>>
49
+
50
+ // Track the rendered snapshot so a replacement client only synchronizes
51
+ // state when its messages actually differ.
52
+ const messagesRef = useRef<Array<UIMessage<TTools>>>(
53
+ options.initialMessages || [],
54
+ )
55
+ const activeClientRef = useRef<ChatClient | null>(null)
56
+ const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(
57
+ null,
58
+ )
59
+
60
+ // Update ref synchronously during render so it's always current when useMemo runs.
61
+ messagesRef.current = messages
62
+
63
+ // Track current options in a ref to avoid recreating client when options change
64
+ const optionsRef = useRef<UseChatOptions<TTools, TSchema, TContext>>(options)
65
+ optionsRef.current = options
66
+
67
+ // Create ChatClient instance with callbacks to sync state
68
+ const client = useMemo(() => {
69
+ const messagesToUse = options.initialMessages || []
70
+
71
+ // Build options with conditional spreads for fields whose source
72
+ // type is `T | undefined` but the ChatClient target uses a strict
73
+ // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects
74
+ // assigning `undefined` to those, so we omit the key when absent.
75
+ const initialOptions = optionsRef.current
76
+ const transport = initialOptions.connection
77
+ ? { connection: initialOptions.connection }
78
+ : { fetcher: initialOptions.fetcher }
79
+
80
+ const instance = new ChatClient<TTools, TContext>({
81
+ devtoolsBridgeFactory: createChatDevtoolsBridge,
82
+ ...transport,
83
+ id: clientId,
84
+ initialMessages: messagesToUse,
85
+ ...(initialOptions.body !== undefined && { body: initialOptions.body }),
86
+ ...(initialOptions.threadId !== undefined && {
87
+ threadId: initialOptions.threadId,
88
+ }),
89
+ ...(initialOptions.forwardedProps !== undefined && {
90
+ forwardedProps: initialOptions.forwardedProps,
91
+ }),
92
+ ...(initialOptions.persistence !== undefined && {
93
+ persistence: initialOptions.persistence,
94
+ }),
95
+ ...(initialOptions.context !== undefined && {
96
+ context: initialOptions.context,
97
+ }),
98
+ devtools: {
99
+ ...initialOptions.devtools,
100
+ // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools.
101
+ framework: 'octane',
102
+ hookName: 'useChat',
103
+ outputKind: initialOptions.outputSchema ? 'structured' : 'chat',
104
+ },
105
+ onResponse: (response) => {
106
+ if (activeClientRef.current !== instance) return
107
+ void optionsRef.current.onResponse?.(response)
108
+ },
109
+ onChunk: (chunk: StreamChunk) => {
110
+ if (activeClientRef.current !== instance) return
111
+ optionsRef.current.onChunk?.(chunk)
112
+ },
113
+ onFinish: (message: UIMessage<TTools>) => {
114
+ if (activeClientRef.current !== instance) return
115
+ optionsRef.current.onFinish?.(message)
116
+ },
117
+ onError: (error: Error) => {
118
+ if (activeClientRef.current !== instance) return
119
+ optionsRef.current.onError?.(error)
120
+ },
121
+ ...(initialOptions.tools !== undefined && {
122
+ tools: initialOptions.tools,
123
+ }),
124
+ onCustomEvent: (eventType, data, context) => {
125
+ if (activeClientRef.current !== instance) return
126
+ optionsRef.current.onCustomEvent?.(eventType, data, context)
127
+ },
128
+ ...(options.streamProcessor !== undefined && {
129
+ streamProcessor: options.streamProcessor,
130
+ }),
131
+ onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
132
+ if (activeClientRef.current !== instance) return
133
+ setMessages(newMessages)
134
+ },
135
+ onLoadingChange: (newIsLoading: boolean) => {
136
+ if (activeClientRef.current !== instance) return
137
+ setIsLoading(newIsLoading)
138
+ },
139
+ onErrorChange: (newError: Error | undefined) => {
140
+ if (activeClientRef.current !== instance) return
141
+ setError(newError)
142
+ },
143
+ onStatusChange: (status: ChatClientState) => {
144
+ if (activeClientRef.current !== instance) return
145
+ setStatus(status)
146
+ },
147
+ onSubscriptionChange: (nextIsSubscribed: boolean) => {
148
+ if (activeClientRef.current !== instance) return
149
+ setIsSubscribed(nextIsSubscribed)
150
+ },
151
+ onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
152
+ if (activeClientRef.current !== instance) return
153
+ setConnectionStatus(nextStatus)
154
+ },
155
+ onSessionGeneratingChange: (isGenerating: boolean) => {
156
+ if (activeClientRef.current !== instance) return
157
+ setSessionGenerating(isGenerating)
158
+ },
159
+ })
160
+ activeClientRef.current = instance
161
+ return instance
162
+ }, [clientId])
163
+
164
+ useEffect(() => {
165
+ const clientMessages = client.getMessages()
166
+ if (clientMessages !== messagesRef.current) {
167
+ setMessages(clientMessages)
168
+ }
169
+ }, [client])
170
+
171
+ // OCTANE DIVERGENCE: upstream captures the initial transport. ChatClient
172
+ // owns transport swaps, including aborting an active request and
173
+ // resubscribing when needed, so keep its conversation state stable while
174
+ // applying the latest transport.
175
+ useEffect(() => {
176
+ const transport = options.connection
177
+ ? { connection: options.connection }
178
+ : { fetcher: options.fetcher }
179
+ client.updateOptions(transport)
180
+ }, [client, options.connection, options.fetcher])
181
+
182
+ // Sync each wire-payload slot in its own effect so an unrelated option
183
+ // changing doesn't re-run the others. `updateOptions` declares strict-optional
184
+ // fields and rejects explicit `undefined` under EOPT, so guard the optional
185
+ // slots before passing them.
186
+ useEffect(() => {
187
+ client.updateOptions({ body: options.body })
188
+ }, [client, options.body])
189
+
190
+ useEffect(() => {
191
+ if (options.forwardedProps !== undefined) {
192
+ client.updateOptions({ forwardedProps: options.forwardedProps })
193
+ }
194
+ }, [client, options.forwardedProps])
195
+
196
+ useEffect(() => {
197
+ if (options.tools !== undefined) {
198
+ client.updateOptions({ tools: options.tools })
199
+ }
200
+ }, [client, options.tools])
201
+
202
+ useEffect(() => {
203
+ client.updateOptions({ context: options.context })
204
+ }, [client, options.context])
205
+
206
+ useEffect(() => {
207
+ if (options.live) {
208
+ client.subscribe()
209
+ } else {
210
+ client.unsubscribe()
211
+ }
212
+ }, [client, options.live])
213
+
214
+ useEffect(() => {
215
+ if (cleanupInvalidationRef.current) {
216
+ clearTimeout(cleanupInvalidationRef.current)
217
+ cleanupInvalidationRef.current = null
218
+ }
219
+ activeClientRef.current = client
220
+ client.mountDevtools()
221
+
222
+ return () => {
223
+ cleanupInvalidationRef.current = setTimeout(() => {
224
+ if (activeClientRef.current === client) {
225
+ activeClientRef.current = null
226
+ }
227
+ cleanupInvalidationRef.current = null
228
+ }, 0)
229
+ // Subscribe/unsubscribe on `options.live` is owned by the dedicated
230
+ // effect above. This cleanup only fires on unmount or client swap,
231
+ // so read `live` through the ref to avoid disposing the client every
232
+ // time `live` toggles.
233
+ if (optionsRef.current.live) {
234
+ client.unsubscribe()
235
+ } else {
236
+ client.stop()
237
+ }
238
+ client.dispose()
239
+ }
240
+ }, [client])
241
+
242
+ const sendMessage = useCallback(
243
+ async (content: string | MultimodalContent) => {
244
+ await client.sendMessage(content)
245
+ },
246
+ [client],
247
+ )
248
+
249
+ const append = useCallback(
250
+ async (message: ModelMessage | UIMessage) => {
251
+ await client.append(message)
252
+ },
253
+ [client],
254
+ )
255
+
256
+ const reload = useCallback(async () => {
257
+ await client.reload()
258
+ }, [client])
259
+
260
+ const stop = useCallback(() => {
261
+ client.stop()
262
+ }, [client])
263
+
264
+ const clear = useCallback(() => {
265
+ client.clear()
266
+ }, [client])
267
+
268
+ const setMessagesManually = useCallback(
269
+ (newMessages: Array<UIMessage<TTools>>) => {
270
+ client.setMessagesManually(newMessages)
271
+ },
272
+ [client],
273
+ )
274
+
275
+ const addToolResult = useCallback(
276
+ async (result: {
277
+ toolCallId: string
278
+ tool: string
279
+ output: any
280
+ state?: 'output-available' | 'output-error'
281
+ errorText?: string
282
+ }) => {
283
+ await client.addToolResult(result)
284
+ },
285
+ [client],
286
+ )
287
+
288
+ const addToolApprovalResponse = useCallback(
289
+ async (response: { id: string; approved: boolean }) => {
290
+ await client.addToolApprovalResponse(response)
291
+ },
292
+ [client],
293
+ )
294
+
295
+ // The "active" structured-output part is the one on the assistant message
296
+ // that follows the latest user message. No such message exists between
297
+ // sendMessage() and the first chunk, so partial/final naturally read as
298
+ // cleared. Historical parts on earlier assistant messages remain available
299
+ // via `messages` directly.
300
+ //
301
+ // When there is NO user message yet (e.g. `initialMessages` contains only
302
+ // a stale assistant turn or a system prompt) we deliberately return null
303
+ // rather than scanning historical assistants — otherwise a `final` from a
304
+ // previous session would leak into the hook value on first render.
305
+ const renderedMessages = client.getMessages()
306
+
307
+ const activeStructuredPart = useMemo<StructuredOutputPart | null>(() => {
308
+ let lastUserIndex = -1
309
+ for (let i = renderedMessages.length - 1; i >= 0; i--) {
310
+ if (renderedMessages[i]?.role === 'user') {
311
+ lastUserIndex = i
312
+ break
313
+ }
314
+ }
315
+ if (lastUserIndex === -1) return null
316
+ for (let i = renderedMessages.length - 1; i > lastUserIndex; i--) {
317
+ const m = renderedMessages[i]
318
+ if (m?.role !== 'assistant') continue
319
+ const part = m.parts.find(
320
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
321
+ )
322
+ if (part) return part
323
+ }
324
+ return null
325
+ }, [renderedMessages])
326
+
327
+ const partial = useMemo<Partial>(() => {
328
+ if (!activeStructuredPart) return {} as Partial
329
+ const v = activeStructuredPart.partial ?? activeStructuredPart.data
330
+ return (v ?? {}) as Partial
331
+ }, [activeStructuredPart])
332
+
333
+ const final = useMemo<Final | null>(() => {
334
+ if (!activeStructuredPart || activeStructuredPart.status !== 'complete') {
335
+ return null
336
+ }
337
+ return activeStructuredPart.data as Final
338
+ }, [activeStructuredPart])
339
+
340
+ // The runtime shape unconditionally exposes partial/final; the public
341
+ // return type hides them when no outputSchema was supplied. TS can't
342
+ // structurally narrow across that conditional, so the `as` is the seam.
343
+ // eslint-disable-next-line no-restricted-syntax -- hook return shape diverges from generic UseChatReturn<TTools, TSchema> due to conditional type on TSchema; TS can't structurally narrow
344
+ return {
345
+ messages: renderedMessages,
346
+ sendMessage,
347
+ append,
348
+ reload,
349
+ stop,
350
+ isLoading,
351
+ error,
352
+ status,
353
+ isSubscribed,
354
+ connectionStatus,
355
+ sessionGenerating,
356
+ setMessages: setMessagesManually,
357
+ clear,
358
+ addToolResult,
359
+ addToolApprovalResponse,
360
+ partial,
361
+ final,
362
+ } as unknown as UseChatReturn<TTools, TSchema>
363
+ }
@@ -0,0 +1,5 @@
1
+ // Declaration companion generated from use-chat.tsrx.
2
+ import type { AnyClientTool, SchemaInput } from '@tanstack/ai/client';
3
+ import type { InferredClientContext } from '@tanstack/ai-client';
4
+ import type { UseChatOptions, UseChatReturn } from './types';
5
+ export declare function useChat<const TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext<TTools>>(options: UseChatOptions<TTools, TSchema, TContext>): UseChatReturn<TTools, TSchema>;
@@ -0,0 +1,125 @@
1
+ import { useGeneration } from './use-generation.tsrx'
2
+ import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai'
3
+ import type {
4
+ AIDevtoolsDisplayOptions,
5
+ AudioGenerateInput,
6
+ ConnectConnectionAdapter,
7
+ GenerationClientState,
8
+ GenerationFetcher,
9
+ InferGenerationOutputFromReturn,
10
+ } from '@tanstack/ai-client'
11
+
12
+ /**
13
+ * Options for the useGenerateAudio hook.
14
+ *
15
+ * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult)
16
+ */
17
+ export interface UseGenerateAudioOptions<TOutput = AudioGenerationResult> {
18
+ /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */
19
+ connection?: ConnectConnectionAdapter
20
+ /** Direct async function for audio generation */
21
+ fetcher?: GenerationFetcher<AudioGenerateInput, AudioGenerationResult>
22
+ /** Unique identifier for this generation instance */
23
+ id?: string
24
+ /** Additional body parameters to send with connect-based adapter requests */
25
+ body?: Record<string, any>
26
+ /** Display options for TanStack AI Devtools. */
27
+ devtools?: AIDevtoolsDisplayOptions
28
+ /**
29
+ * Callback when audio is generated. Can optionally return a transformed value.
30
+ *
31
+ * - Return a non-null value to transform and store it as the result
32
+ * - Return `null` to keep the previous result unchanged
33
+ * - Return nothing (`void`) to store the raw result as-is
34
+ */
35
+ onResult?: (result: AudioGenerationResult) => TOutput | null | void
36
+ /** Callback when an error occurs */
37
+ onError?: (error: Error) => void
38
+ /** Callback when progress is reported (0-100) */
39
+ onProgress?: (progress: number, message?: string) => void
40
+ /** Callback for each stream chunk (connect-based adapter mode only) */
41
+ onChunk?: (chunk: StreamChunk) => void
42
+ }
43
+
44
+ /**
45
+ * Return type for the useGenerateAudio hook.
46
+ *
47
+ * @template TOutput - The output type (after optional transform)
48
+ */
49
+ export interface UseGenerateAudioReturn<TOutput = AudioGenerationResult> {
50
+ /** Trigger audio generation */
51
+ generate: (input: AudioGenerateInput) => Promise<void>
52
+ /** The generation result containing audio, or null */
53
+ result: TOutput | null
54
+ /** Whether generation is in progress */
55
+ isLoading: boolean
56
+ /** Current error, if any */
57
+ error: Error | undefined
58
+ /** Current state of the generation */
59
+ status: GenerationClientState
60
+ /** Abort the current generation */
61
+ stop: () => void
62
+ /** Clear result, error, and return to idle */
63
+ reset: () => void
64
+ }
65
+
66
+ /**
67
+ * Octane hook for generating audio (music, sound effects) using AI models.
68
+ *
69
+ * Supports two transport modes:
70
+ * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom)
71
+ * - **Fetcher** — Direct async function call
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * import { useGenerateAudio } from '@octanejs/tanstack-ai'
76
+ * import { fetchServerSentEvents } from '@tanstack/ai-client'
77
+ *
78
+ * function AudioGenerator() {
79
+ * const { generate, result, isLoading, error, reset } = useGenerateAudio({
80
+ * connection: fetchServerSentEvents('/api/generate/audio'),
81
+ * })
82
+ *
83
+ * return (
84
+ * <div>
85
+ * <button onClick={() => generate({ prompt: 'An upbeat electronic track', duration: 10 })}>
86
+ * Generate
87
+ * </button>
88
+ * {isLoading && <p>Generating...</p>}
89
+ * {error && <p>Error: {error.message}</p>}
90
+ * {result?.audio.url && <audio src={result.audio.url} controls />}
91
+ * </div>
92
+ * )
93
+ * }
94
+ * ```
95
+ */
96
+ export function useGenerateAudio<TTransformed = void>(
97
+ options: Omit<UseGenerateAudioOptions, 'onResult'> & {
98
+ onResult?: (result: AudioGenerationResult) => TTransformed
99
+ },
100
+ ): UseGenerateAudioReturn<
101
+ InferGenerationOutputFromReturn<AudioGenerationResult, TTransformed>
102
+ > {
103
+ const devtools = {
104
+ ...options.devtools,
105
+ // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools.
106
+ framework: 'octane',
107
+ hookName: 'useGenerateAudio',
108
+ outputKind: 'audio' as const,
109
+ }
110
+ const { generate, result, isLoading, error, status, stop, reset } =
111
+ useGeneration<AudioGenerateInput, AudioGenerationResult, TTransformed>({
112
+ ...options,
113
+ devtools,
114
+ })
115
+
116
+ return {
117
+ generate: generate as (input: AudioGenerateInput) => Promise<void>,
118
+ result,
119
+ isLoading,
120
+ error,
121
+ status,
122
+ stop,
123
+ reset,
124
+ }
125
+ }