@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.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/package.json +73 -0
- package/src/index.ts +95 -0
- package/src/realtime-types.ts +146 -0
- package/src/types.ts +305 -0
- package/src/use-audio-recorder.tsrx +118 -0
- package/src/use-audio-recorder.tsrx.d.ts +54 -0
- package/src/use-chat.tsrx +587 -0
- package/src/use-chat.tsrx.d.ts +19 -0
- package/src/use-generate-audio.tsrx +125 -0
- package/src/use-generate-audio.tsrx.d.ts +99 -0
- package/src/use-generate-image.tsrx +127 -0
- package/src/use-generate-image.tsrx.d.ts +101 -0
- package/src/use-generate-speech.tsrx +121 -0
- package/src/use-generate-speech.tsrx.d.ts +95 -0
- package/src/use-generate-video.tsrx +245 -0
- package/src/use-generate-video.tsrx.d.ts +108 -0
- package/src/use-generation.tsrx +229 -0
- package/src/use-generation.tsrx.d.ts +103 -0
- package/src/use-mcp-app-bridge.tsrx +60 -0
- package/src/use-mcp-app-bridge.tsrx.d.ts +31 -0
- package/src/use-realtime-chat.tsrx +307 -0
- package/src/use-realtime-chat.tsrx.d.ts +48 -0
- package/src/use-summarize.tsrx +124 -0
- package/src/use-summarize.tsrx.d.ts +98 -0
- package/src/use-transcription.tsrx +131 -0
- package/src/use-transcription.tsrx.d.ts +104 -0
|
@@ -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>
|