@tanstack/ai-octane 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,587 @@
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
+ InterruptDefinition,
8
+ ModelMessage,
9
+ RunAgentResumeItem,
10
+ SchemaInput,
11
+ StreamChunk,
12
+ } from '@tanstack/ai/client'
13
+ import type {
14
+ ChatClientState,
15
+ ResolvableChatInterrupt,
16
+ ChatInterruptState,
17
+ ChatResumeState,
18
+ ConnectionStatus,
19
+ InferredClientContext,
20
+ QueuedMessage,
21
+ SendMessageOptions,
22
+ StructuredOutputPart,
23
+ } from '@tanstack/ai-client'
24
+
25
+ import type {
26
+ DeepPartial,
27
+ MultimodalContent,
28
+ UIMessage,
29
+ UseChatOptions,
30
+ UseChatReturn,
31
+ } from './types'
32
+
33
+ const EMPTY_INTERRUPTS = Object.freeze([])
34
+ const EMPTY_INTERRUPT_ERRORS = Object.freeze([])
35
+
36
+ export function useChat<
37
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
38
+ TSchema extends SchemaInput | undefined = undefined,
39
+ TContext = InferredClientContext<TTools>,
40
+ const TInterrupts extends ReadonlyArray<
41
+ InterruptDefinition<any, any, any, any>
42
+ > = readonly [],
43
+ >(
44
+ options: UseChatOptions<TTools, TSchema, TContext, TInterrupts>,
45
+ ): UseChatReturn<TTools, TSchema, TInterrupts> {
46
+ // The hook's identity is its `threadId`. Reload with the same `threadId`
47
+ // restores the same conversation. `hookId` is only a recreation key when no
48
+ // `threadId` is given. It is never sent on the wire.
49
+ const hookId = useId()
50
+ const clientId = options.threadId ?? hookId
51
+
52
+ const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(
53
+ options.initialMessages || [],
54
+ )
55
+ const [isLoading, setIsLoading] = useState(false)
56
+ const [error, setError] = useState<Error | undefined>(undefined)
57
+ const [status, setStatus] = useState<ChatClientState>('ready')
58
+ const [isSubscribed, setIsSubscribed] = useState(false)
59
+ const [connectionStatus, setConnectionStatus] =
60
+ useState<ConnectionStatus>('disconnected')
61
+ const [sessionGenerating, setSessionGenerating] = useState(false)
62
+ const [queue, setQueue] = useState<Array<QueuedMessage>>([])
63
+ const [runId, setRunId] = useState<string | null>(null)
64
+ const [interruptState, setInterruptState] = useState<
65
+ ChatInterruptState<TTools, TInterrupts>
66
+ >(() => ({
67
+ interrupts: EMPTY_INTERRUPTS,
68
+ pendingInterrupts: EMPTY_INTERRUPTS,
69
+ interruptErrors: EMPTY_INTERRUPT_ERRORS,
70
+ resuming: false,
71
+ }))
72
+
73
+ type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
74
+ type Final = InferSchemaType<NonNullable<TSchema>>
75
+
76
+ // Track the rendered snapshot so a replacement client only synchronizes
77
+ // state when its messages actually differ.
78
+ const messagesRef = useRef<Array<UIMessage<TTools>>>(
79
+ options.initialMessages || [],
80
+ )
81
+ const subscribedRef = useRef(false)
82
+ const activeClientRef = useRef<ChatClient | null>(null)
83
+ const seenClientRef = useRef<ChatClient | null>(null)
84
+
85
+ // Update ref synchronously during render so it's always current when useMemo runs.
86
+ messagesRef.current = messages
87
+
88
+ // Track current options in a ref to avoid recreating client when options change
89
+ const optionsRef =
90
+ useRef<UseChatOptions<TTools, TSchema, TContext, TInterrupts>>(options)
91
+ optionsRef.current = options
92
+
93
+ const syncResumeState = useCallback((target: ChatClient | null) => {
94
+ if (!target) return
95
+ setRunId(target.getCurrentRunId())
96
+ setInterruptState(target.getInterruptState())
97
+ }, [])
98
+
99
+ // Create ChatClient instance with callbacks to sync state
100
+ const { client, initialization } = useMemo(() => {
101
+ const messagesToUse = options.initialMessages || []
102
+
103
+ // Build options with conditional spreads for fields whose source
104
+ // type is `T | undefined` but the ChatClient target uses a strict
105
+ // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects
106
+ // assigning `undefined` to those, so we omit the key when absent.
107
+ const initialOptions = optionsRef.current
108
+ const transport = initialOptions.connection
109
+ ? { connection: initialOptions.connection }
110
+ : { fetcher: initialOptions.fetcher }
111
+
112
+ const instanceHolder: {
113
+ current: ChatClient<TTools, TContext, TInterrupts> | undefined
114
+ } = { current: undefined }
115
+ const getActiveInstance = () => {
116
+ const currentInstance = instanceHolder.current
117
+ if (!currentInstance || activeClientRef.current !== currentInstance) {
118
+ return undefined
119
+ }
120
+ return currentInstance
121
+ }
122
+ // ChatClient may publish while its constructor is running or while async
123
+ // persistence resolves before commit. Preserve those notifications until
124
+ // this render commits.
125
+ const initializationState = {
126
+ ready: false,
127
+ callbacks: [] as Array<() => void>,
128
+ }
129
+ const runOrQueueForActiveInstance = (callback: () => void) => {
130
+ if (!initializationState.ready) {
131
+ initializationState.callbacks.push(callback)
132
+ return
133
+ }
134
+ const currentInstance = instanceHolder.current
135
+ if (!currentInstance || activeClientRef.current !== currentInstance)
136
+ return
137
+ callback()
138
+ }
139
+ const instance = new ChatClient<TTools, TContext, TInterrupts>({
140
+ devtoolsBridgeFactory: createChatDevtoolsBridge,
141
+ ...transport,
142
+ initialMessages: messagesToUse,
143
+ ...(typeof initialOptions.threadId === 'string' &&
144
+ initialOptions.persistence
145
+ ? {
146
+ persistence: initialOptions.persistence,
147
+ threadId: initialOptions.threadId,
148
+ }
149
+ : {
150
+ ...(initialOptions.threadId !== undefined && {
151
+ threadId: initialOptions.threadId,
152
+ }),
153
+ }),
154
+ ...(initialOptions.body !== undefined && { body: initialOptions.body }),
155
+ ...(initialOptions.forwardedProps !== undefined && {
156
+ forwardedProps: initialOptions.forwardedProps,
157
+ }),
158
+ ...(initialOptions.initialResumeSnapshot !== undefined && {
159
+ initialResumeSnapshot: initialOptions.initialResumeSnapshot,
160
+ }),
161
+ ...(initialOptions.context !== undefined && {
162
+ context: initialOptions.context,
163
+ }),
164
+ devtools: {
165
+ ...initialOptions.devtools,
166
+ // OCTANE DIVERGENCE: identify this binding as Octane (not 'react') in TanStack AI Devtools.
167
+ // Spread AFTER caller metadata so callers cannot override identification.
168
+ framework: 'octane',
169
+ hookName: 'useChat',
170
+ outputKind: initialOptions.outputSchema ? 'structured' : 'chat',
171
+ },
172
+ onResponse: (response) => {
173
+ // ChatClient awaits this return value. Dropping it would skip the promise.
174
+ if (!getActiveInstance()) return
175
+ return optionsRef.current.onResponse?.(response)
176
+ },
177
+ onChunk: (chunk: StreamChunk) => {
178
+ runOrQueueForActiveInstance(() => {
179
+ optionsRef.current.onChunk?.(chunk)
180
+ })
181
+ },
182
+ onFinish: (message: UIMessage<TTools>) => {
183
+ runOrQueueForActiveInstance(() => {
184
+ optionsRef.current.onFinish?.(message)
185
+ })
186
+ },
187
+ onError: (error: Error) => {
188
+ runOrQueueForActiveInstance(() => {
189
+ optionsRef.current.onError?.(error)
190
+ })
191
+ },
192
+ ...(initialOptions.tools !== undefined && {
193
+ tools: initialOptions.tools,
194
+ }),
195
+ ...(initialOptions.interrupts !== undefined && {
196
+ interrupts: initialOptions.interrupts,
197
+ }),
198
+ onCustomEvent: (eventType, data, context) => {
199
+ runOrQueueForActiveInstance(() => {
200
+ optionsRef.current.onCustomEvent?.(eventType, data, context)
201
+ })
202
+ },
203
+ ...(options.streamProcessor !== undefined && {
204
+ streamProcessor: options.streamProcessor,
205
+ }),
206
+ onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
207
+ runOrQueueForActiveInstance(() => {
208
+ setMessages(newMessages)
209
+ })
210
+ },
211
+ onLoadingChange: (newIsLoading: boolean) => {
212
+ runOrQueueForActiveInstance(() => {
213
+ const currentInstance = getActiveInstance()
214
+ if (!currentInstance) return
215
+ setIsLoading(newIsLoading)
216
+ syncResumeState(currentInstance)
217
+ })
218
+ },
219
+ onErrorChange: (newError: Error | undefined) => {
220
+ runOrQueueForActiveInstance(() => {
221
+ setError(newError)
222
+ })
223
+ },
224
+ onStatusChange: (status: ChatClientState) => {
225
+ runOrQueueForActiveInstance(() => {
226
+ setStatus(status)
227
+ })
228
+ },
229
+ onSubscriptionChange: (nextIsSubscribed: boolean) => {
230
+ runOrQueueForActiveInstance(() => {
231
+ setIsSubscribed(nextIsSubscribed)
232
+ })
233
+ },
234
+ onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
235
+ runOrQueueForActiveInstance(() => {
236
+ setConnectionStatus(nextStatus)
237
+ })
238
+ },
239
+ onSessionGeneratingChange: (isGenerating: boolean) => {
240
+ runOrQueueForActiveInstance(() => {
241
+ setSessionGenerating(isGenerating)
242
+ })
243
+ },
244
+ ...(optionsRef.current.queue !== undefined && {
245
+ queue: optionsRef.current.queue,
246
+ }),
247
+ onQueueChange: (nextQueue: Array<QueuedMessage>) => {
248
+ runOrQueueForActiveInstance(() => {
249
+ setQueue(nextQueue)
250
+ })
251
+ },
252
+ onRunIdChange: (nextRunId) => {
253
+ runOrQueueForActiveInstance(() => {
254
+ setRunId(nextRunId)
255
+ })
256
+ },
257
+ onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => {
258
+ runOrQueueForActiveInstance(() => {
259
+ setInterruptState((current) => ({
260
+ ...current,
261
+ interrupts: nextPendingInterrupts,
262
+ pendingInterrupts: nextPendingInterrupts,
263
+ }))
264
+ })
265
+ },
266
+ onInterruptStateChange: (nextInterruptState, context) => {
267
+ runOrQueueForActiveInstance(() => {
268
+ setInterruptState(nextInterruptState)
269
+ optionsRef.current.onInterruptStateChange?.(
270
+ nextInterruptState,
271
+ context,
272
+ )
273
+ })
274
+ },
275
+ })
276
+ instanceHolder.current = instance
277
+ return { client: instance, initialization: initializationState }
278
+ }, [clientId, syncResumeState])
279
+
280
+ useEffect(() => {
281
+ activeClientRef.current = client
282
+ try {
283
+ // Keep initialization closed while draining so callbacks published by a
284
+ // queued callback are appended and delivered in the same commit.
285
+ while (initialization.callbacks.length > 0) {
286
+ if (activeClientRef.current !== client) {
287
+ initialization.callbacks.length = 0
288
+ break
289
+ }
290
+ initialization.callbacks.shift()?.()
291
+ }
292
+ } finally {
293
+ // A throw from a queued user callback must not leave the queue closed.
294
+ initialization.ready = true
295
+ }
296
+ }, [client, initialization])
297
+
298
+ useEffect(() => {
299
+ const clientMessages = client.getMessages()
300
+ if (clientMessages !== messagesRef.current) {
301
+ setMessages(clientMessages)
302
+ }
303
+ }, [client])
304
+
305
+ // OCTANE DIVERGENCE: upstream captures the initial transport. ChatClient
306
+ // owns transport swaps, including aborting an active request and
307
+ // resubscribing when needed, so keep its conversation state stable while
308
+ // applying the latest transport. Skip the first run for each client — it
309
+ // already has this transport from the constructor, and a no-op swap can
310
+ // abort constructor rejoin / interrupt resume.
311
+ useEffect(() => {
312
+ if (seenClientRef.current !== client) {
313
+ seenClientRef.current = client
314
+ return
315
+ }
316
+ const transport = options.connection
317
+ ? { connection: options.connection }
318
+ : { fetcher: options.fetcher }
319
+ client.updateOptions(transport)
320
+ }, [client, options.connection, options.fetcher])
321
+
322
+ // Sync each wire-payload slot in its own effect so an unrelated option
323
+ // changing doesn't re-run the others. `updateOptions` declares strict-optional
324
+ // fields and rejects explicit `undefined` under EOPT, so guard the optional
325
+ // slots before passing them.
326
+ useEffect(() => {
327
+ client.updateOptions({ body: options.body })
328
+ }, [client, options.body])
329
+
330
+ useEffect(() => {
331
+ if (options.forwardedProps !== undefined) {
332
+ client.updateOptions({ forwardedProps: options.forwardedProps })
333
+ }
334
+ }, [client, options.forwardedProps])
335
+
336
+ useEffect(() => {
337
+ if (options.tools !== undefined) {
338
+ client.updateOptions({ tools: options.tools })
339
+ }
340
+ }, [client, options.tools])
341
+
342
+ useEffect(() => {
343
+ client.updateOptions({ context: options.context })
344
+ }, [client, options.context])
345
+
346
+ useEffect(() => {
347
+ if (options.queue !== undefined) {
348
+ client.updateOptions({ queue: options.queue })
349
+ }
350
+ }, [client, options.queue])
351
+
352
+ useEffect(() => {
353
+ if (options.live) {
354
+ client.subscribe()
355
+ subscribedRef.current = true
356
+ } else if (subscribedRef.current) {
357
+ // Only tear down a subscription we actually started. Calling
358
+ // `unsubscribe()` on initial mount (when `live` was never enabled) would
359
+ // abort an in-flight delivery resume — `resumeInFlightRun` is kicked off
360
+ // in the client constructor, and `unsubscribe()` cancels the shared
361
+ // in-flight stream.
362
+ client.unsubscribe()
363
+ subscribedRef.current = false
364
+ }
365
+ }, [client, options.live])
366
+
367
+ // Octane has no StrictMode double-invoke, so attach and dispose are
368
+ // immediate (Vue-style), not React's deferred setTimeout dispose.
369
+ useEffect(() => {
370
+ client.attach()
371
+ client.mountDevtools()
372
+ syncResumeState(client)
373
+ return () => {
374
+ client.detach()
375
+ if (optionsRef.current.live) {
376
+ client.unsubscribe()
377
+ } else {
378
+ client.stop()
379
+ }
380
+ client.dispose()
381
+ if (activeClientRef.current === client) {
382
+ activeClientRef.current = null
383
+ }
384
+ }
385
+ }, [client, syncResumeState])
386
+
387
+ const sendMessage = useCallback(
388
+ async (
389
+ content: string | MultimodalContent,
390
+ sendOptions?: SendMessageOptions,
391
+ ) => {
392
+ try {
393
+ await client.sendMessage(content, undefined, sendOptions)
394
+ } finally {
395
+ syncResumeState(client)
396
+ }
397
+ },
398
+ [client, syncResumeState],
399
+ )
400
+
401
+ const cancelQueued = useCallback(
402
+ (id: string) => {
403
+ client.cancelQueued(id)
404
+ },
405
+ [client],
406
+ )
407
+
408
+ const append = useCallback(
409
+ async (message: ModelMessage | UIMessage) => {
410
+ try {
411
+ await client.append(message)
412
+ } finally {
413
+ syncResumeState(client)
414
+ }
415
+ },
416
+ [client, syncResumeState],
417
+ )
418
+
419
+ const reload = useCallback(async () => {
420
+ try {
421
+ await client.reload()
422
+ } finally {
423
+ syncResumeState(client)
424
+ }
425
+ }, [client, syncResumeState])
426
+
427
+ const stop = useCallback(() => {
428
+ client.stop()
429
+ }, [client])
430
+
431
+ const clear = useCallback(() => {
432
+ client.clear()
433
+ syncResumeState(client)
434
+ }, [client, syncResumeState])
435
+
436
+ const setMessagesManually = useCallback(
437
+ (newMessages: Array<UIMessage<TTools>>) => {
438
+ client.setMessagesManually(newMessages)
439
+ },
440
+ [client],
441
+ )
442
+
443
+ const addToolResult = useCallback(
444
+ async (result: {
445
+ toolCallId: string
446
+ tool: string
447
+ output: any
448
+ state?: 'output-available' | 'output-error'
449
+ errorText?: string
450
+ }) => {
451
+ await client.addToolResult(result)
452
+ },
453
+ [client],
454
+ )
455
+
456
+ const addToolApprovalResponse = useCallback(
457
+ async (response: { id: string; approved: boolean }) => {
458
+ await client.addToolApprovalResponse(response)
459
+ syncResumeState(client)
460
+ },
461
+ [client, syncResumeState],
462
+ )
463
+
464
+ const resumeInterrupts = useCallback(
465
+ async (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) => {
466
+ const result = await client.resumeInterrupts(
467
+ resumeItems,
468
+ state ?? undefined,
469
+ )
470
+ syncResumeState(client)
471
+ return result
472
+ },
473
+ [client, syncResumeState],
474
+ )
475
+
476
+ const resolveInterrupts = useCallback(
477
+ (
478
+ resolution:
479
+ | boolean
480
+ | ((
481
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
482
+ ) => undefined),
483
+ ) => {
484
+ if (typeof resolution === 'boolean') {
485
+ client.resolveInterrupts(resolution)
486
+ } else {
487
+ client.resolveInterrupts(resolution)
488
+ }
489
+ },
490
+ [client],
491
+ )
492
+
493
+ const cancelInterrupts = useCallback(() => {
494
+ client.cancelInterrupts()
495
+ }, [client])
496
+
497
+ const retryInterrupts = useCallback(() => {
498
+ client.retryInterrupts()
499
+ }, [client])
500
+
501
+ const resumeInterruptsUnsafe = useCallback(
502
+ (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) =>
503
+ client.resumeInterruptsUnsafe(resumeItems, state),
504
+ [client],
505
+ )
506
+
507
+ // The "active" structured-output part is the one on the assistant message
508
+ // that follows the latest user message. No such message exists between
509
+ // sendMessage() and the first chunk, so partial/final naturally read as
510
+ // cleared. Historical parts on earlier assistant messages remain available
511
+ // via `messages` directly.
512
+ //
513
+ // When there is NO user message yet (e.g. `initialMessages` contains only
514
+ // a stale assistant turn or a system prompt) we deliberately return null
515
+ // rather than scanning historical assistants — otherwise a `final` from a
516
+ // previous session would leak into the hook value on first render.
517
+ const renderedMessages = client.getMessages()
518
+
519
+ const activeStructuredPart = useMemo<StructuredOutputPart | null>(() => {
520
+ let lastUserIndex = -1
521
+ for (let i = renderedMessages.length - 1; i >= 0; i--) {
522
+ if (renderedMessages[i]?.role === 'user') {
523
+ lastUserIndex = i
524
+ break
525
+ }
526
+ }
527
+ if (lastUserIndex === -1) return null
528
+ for (let i = renderedMessages.length - 1; i > lastUserIndex; i--) {
529
+ const m = renderedMessages[i]
530
+ if (m?.role !== 'assistant') continue
531
+ const part = m.parts.find(
532
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
533
+ )
534
+ if (part) return part
535
+ }
536
+ return null
537
+ }, [renderedMessages])
538
+
539
+ const partial = useMemo<Partial>(() => {
540
+ if (!activeStructuredPart) return {} as Partial
541
+ const v = activeStructuredPart.partial ?? activeStructuredPart.data
542
+ return (v ?? {}) as Partial
543
+ }, [activeStructuredPart])
544
+
545
+ const final = useMemo<Final | null>(() => {
546
+ if (!activeStructuredPart || activeStructuredPart.status !== 'complete') {
547
+ return null
548
+ }
549
+ return activeStructuredPart.data as Final
550
+ }, [activeStructuredPart])
551
+
552
+ // The runtime shape unconditionally exposes partial/final; the public
553
+ // return type hides them when no outputSchema was supplied. TS can't
554
+ // structurally narrow across that conditional, so the `as` is the seam.
555
+ // 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
556
+ return {
557
+ messages: renderedMessages,
558
+ sendMessage,
559
+ append,
560
+ reload,
561
+ stop,
562
+ isLoading,
563
+ error,
564
+ status,
565
+ isSubscribed,
566
+ connectionStatus,
567
+ sessionGenerating,
568
+ setMessages: setMessagesManually,
569
+ clear,
570
+ addToolResult,
571
+ addToolApprovalResponse,
572
+ queue,
573
+ cancelQueued,
574
+ runId,
575
+ interrupts: interruptState.interrupts,
576
+ pendingInterrupts: interruptState.pendingInterrupts,
577
+ interruptErrors: interruptState.interruptErrors,
578
+ resuming: interruptState.resuming,
579
+ resolveInterrupts,
580
+ cancelInterrupts,
581
+ retryInterrupts,
582
+ resumeInterruptsUnsafe,
583
+ resumeInterrupts,
584
+ partial,
585
+ final,
586
+ } as unknown as UseChatReturn<TTools, TSchema, TInterrupts>
587
+ }
@@ -0,0 +1,19 @@
1
+ // Declaration companion generated from use-chat.tsrx.
2
+ import type {
3
+ AnyClientTool,
4
+ InterruptDefinition,
5
+ SchemaInput,
6
+ } from '@tanstack/ai/client'
7
+ import type { InferredClientContext } from '@tanstack/ai-client'
8
+ import type { UseChatOptions, UseChatReturn } from './types'
9
+
10
+ export declare function useChat<
11
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
12
+ TSchema extends SchemaInput | undefined = undefined,
13
+ TContext = InferredClientContext<TTools>,
14
+ const TInterrupts extends ReadonlyArray<
15
+ InterruptDefinition<any, any, any, any>
16
+ > = readonly [],
17
+ >(
18
+ options: UseChatOptions<TTools, TSchema, TContext, TInterrupts>,
19
+ ): UseChatReturn<TTools, TSchema, TInterrupts>