@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,516 @@
|
|
|
1
|
+
import {
|
|
2
|
+
automaticPartsForMessage,
|
|
3
|
+
collectInlineToolNames,
|
|
4
|
+
resolveInterruptComponent,
|
|
5
|
+
selectChatUI,
|
|
6
|
+
selectMessageUI,
|
|
7
|
+
} from '@tanstack/ai-client/ui'
|
|
8
|
+
import type {
|
|
9
|
+
ChatUIData,
|
|
10
|
+
ChatUIHasNamedInterrupts,
|
|
11
|
+
ChatUIHasNamedTools,
|
|
12
|
+
ChatUIInterrupt,
|
|
13
|
+
ChatUIInterruptName,
|
|
14
|
+
ChatUIInterruptOf,
|
|
15
|
+
ChatUIInterruptsOf,
|
|
16
|
+
ChatUIMessages,
|
|
17
|
+
ChatUINamedInterruptId,
|
|
18
|
+
ChatUIPartKey,
|
|
19
|
+
ChatUIPartOf,
|
|
20
|
+
ChatUISchemaOf,
|
|
21
|
+
ChatUISelectedPart,
|
|
22
|
+
ChatUIToolApproval,
|
|
23
|
+
ChatUIToolName,
|
|
24
|
+
ChatUIToolsOf,
|
|
25
|
+
} from '@tanstack/ai-client/ui'
|
|
26
|
+
import type {
|
|
27
|
+
MessagePart,
|
|
28
|
+
QueuedMessage,
|
|
29
|
+
ToolCallPart,
|
|
30
|
+
ToolResultPart,
|
|
31
|
+
UIMessage,
|
|
32
|
+
} from '@tanstack/ai-client'
|
|
33
|
+
import type { Handle, RemixNode } from 'remix/ui'
|
|
34
|
+
import type { CreateChatReturn } from '../types.ts'
|
|
35
|
+
|
|
36
|
+
export type ChatUIHost<TOptions = unknown> = CreateChatReturn<
|
|
37
|
+
ChatUIToolsOf<TOptions>,
|
|
38
|
+
ChatUISchemaOf<TOptions>,
|
|
39
|
+
ChatUIInterruptsOf<TOptions>
|
|
40
|
+
>
|
|
41
|
+
|
|
42
|
+
export type ChatUIQueueItem = QueuedMessage & {
|
|
43
|
+
cancelQueued: () => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RemixComp<P = any> = (handle: Handle<P>) => () => RemixNode
|
|
47
|
+
|
|
48
|
+
export type LayoutProps<TOptions> = {
|
|
49
|
+
Messages: RemixComp
|
|
50
|
+
Interrupts: RemixComp
|
|
51
|
+
Queue: RemixComp
|
|
52
|
+
Input: RemixComp
|
|
53
|
+
readonly __ui?: TOptions
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MessageProps<TOptions> = {
|
|
57
|
+
message: UIMessage<ChatUIToolsOf<TOptions>, ChatUIData<TOptions>>
|
|
58
|
+
Parts: RemixComp
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type InputProps<TOptions> = {
|
|
62
|
+
readonly __ui?: TOptions
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type QueueProps<TOptions> = {
|
|
66
|
+
item: ChatUIQueueItem
|
|
67
|
+
readonly __ui?: TOptions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PartProps<TOptions, TKey extends ChatUIPartKey = ChatUIPartKey> = {
|
|
71
|
+
part: ChatUIPartOf<TOptions, TKey>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ToolProps<
|
|
75
|
+
TOptions,
|
|
76
|
+
TName extends ChatUIToolName<TOptions> = ChatUIToolName<TOptions>,
|
|
77
|
+
> = {
|
|
78
|
+
part: Extract<ToolCallPart<ChatUIToolsOf<TOptions>>, { name: TName }>
|
|
79
|
+
result?: ToolResultPart
|
|
80
|
+
interrupt?: ChatUIToolApproval<TOptions, TName>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type InterruptProps<
|
|
84
|
+
TOptions,
|
|
85
|
+
TName extends ChatUIInterruptName<TOptions> = never,
|
|
86
|
+
> = {
|
|
87
|
+
interrupt: ChatUIInterruptOf<TOptions, TName>
|
|
88
|
+
readonly __ui?: TOptions
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type GenericInterruptComponents<TOptions> =
|
|
92
|
+
ChatUIHasNamedInterrupts<TOptions> extends true
|
|
93
|
+
? {
|
|
94
|
+
[K in ChatUINamedInterruptId<TOptions>]: RemixComp<
|
|
95
|
+
InterruptProps<TOptions, K & ChatUIInterruptName<TOptions>>
|
|
96
|
+
>
|
|
97
|
+
} & {
|
|
98
|
+
fallback?: RemixComp<InterruptProps<TOptions>>
|
|
99
|
+
}
|
|
100
|
+
: {
|
|
101
|
+
fallback?: RemixComp<InterruptProps<TOptions>>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type ToolApprovalMap<TOptions> = {
|
|
105
|
+
[K in ChatUIToolName<TOptions>]?: RemixComp<
|
|
106
|
+
InterruptProps<TOptions, K & ChatUIInterruptName<TOptions>>
|
|
107
|
+
>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The chrome around the message list: `layout`, `message`, and `input`. */
|
|
111
|
+
export type ChatUIChromeComponents<TOptions> = {
|
|
112
|
+
layout: RemixComp<LayoutProps<TOptions>>
|
|
113
|
+
message: RemixComp<MessageProps<TOptions>>
|
|
114
|
+
input?: RemixComp<InputProps<TOptions>>
|
|
115
|
+
queue?: RemixComp<QueueProps<TOptions>>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ChatUIPartsComponents<TOptions> = {
|
|
119
|
+
[K in ChatUIPartKey]?: RemixComp<PartProps<TOptions, K>>
|
|
120
|
+
} & {
|
|
121
|
+
fallback?: RemixComp<PartProps<TOptions>>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type ChatUIInterruptsComponents<TOptions> = {
|
|
125
|
+
tools?: ToolApprovalMap<TOptions>
|
|
126
|
+
generic: GenericInterruptComponents<TOptions>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type ChatUIComponents<TOptions> = {
|
|
130
|
+
components: ChatUIChromeComponents<TOptions>
|
|
131
|
+
partsComponents: ChatUIPartsComponents<TOptions>
|
|
132
|
+
} & (ChatUIHasNamedTools<TOptions> extends true
|
|
133
|
+
? {
|
|
134
|
+
toolsComponents: {
|
|
135
|
+
[K in ChatUIToolName<TOptions>]: RemixComp<ToolProps<TOptions, K>>
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
: {
|
|
139
|
+
toolsComponents?: {
|
|
140
|
+
[K in ChatUIToolName<TOptions>]?: RemixComp<ToolProps<TOptions, K>>
|
|
141
|
+
}
|
|
142
|
+
}) &
|
|
143
|
+
(ChatUIHasNamedInterrupts<TOptions> extends true
|
|
144
|
+
? { interruptsComponents: ChatUIInterruptsComponents<TOptions> }
|
|
145
|
+
: {
|
|
146
|
+
interruptsComponents?: {
|
|
147
|
+
tools?: ToolApprovalMap<TOptions>
|
|
148
|
+
generic?: GenericInterruptComponents<TOptions>
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
export type ChatUIFactoryConfig<TOptions> = ChatUIComponents<TOptions>
|
|
153
|
+
|
|
154
|
+
type ComponentsValue<TOptions> = {
|
|
155
|
+
chat: ChatUIHost<TOptions>
|
|
156
|
+
warn: (key: string, message: string) => void
|
|
157
|
+
inlineToolNames: ReadonlyArray<string>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type ProviderProps<TOptions> = {
|
|
161
|
+
chat: ChatUIHost<TOptions>
|
|
162
|
+
children?: RemixNode
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type MessageRenderValue<TOptions> = {
|
|
166
|
+
message: ChatUIMessages<TOptions>[number]
|
|
167
|
+
interrupts: ReadonlyArray<ChatUIInterrupt>
|
|
168
|
+
inlineToolNames: ReadonlyArray<string>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createWarnOnce() {
|
|
172
|
+
const seen = new Set<string>()
|
|
173
|
+
return (key: string, message: string) => {
|
|
174
|
+
if (process.env.NODE_ENV === 'production') return
|
|
175
|
+
if (seen.has(key)) return
|
|
176
|
+
seen.add(key)
|
|
177
|
+
console.warn(message)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readMessages<TOptions>(chat: ChatUIHost<TOptions>) {
|
|
182
|
+
return chat.messages as ChatUIMessages<TOptions>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readInterrupts<TOptions>(chat: ChatUIHost<TOptions>) {
|
|
186
|
+
return chat.interrupts ?? []
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Bind chat options and UI widgets once at module scope. This matches Form
|
|
191
|
+
* `createFormHook` and Table `createTableHook`.
|
|
192
|
+
*
|
|
193
|
+
* `chatOptions` is type-only at runtime. Widgets register here in named
|
|
194
|
+
* groups. `<ui.Chat chat={chat} />` closes over them, so you do not pass
|
|
195
|
+
* a components prop at render time.
|
|
196
|
+
*/
|
|
197
|
+
export function createChatUI<const TOptions>(
|
|
198
|
+
options: TOptions,
|
|
199
|
+
config: ChatUIFactoryConfig<NoInfer<TOptions>>,
|
|
200
|
+
) {
|
|
201
|
+
void options
|
|
202
|
+
const {
|
|
203
|
+
components,
|
|
204
|
+
partsComponents: parts,
|
|
205
|
+
toolsComponents: tools,
|
|
206
|
+
interruptsComponents: interrupts,
|
|
207
|
+
} = config as ChatUIFactoryConfig<TOptions> & {
|
|
208
|
+
toolsComponents?: Record<string, RemixComp<any> | undefined>
|
|
209
|
+
interruptsComponents?: {
|
|
210
|
+
tools?: Record<string, RemixComp<any> | undefined>
|
|
211
|
+
generic?: Record<string, RemixComp<any> | undefined>
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const {
|
|
215
|
+
layout: Layout,
|
|
216
|
+
message: MessageComponent,
|
|
217
|
+
input: InputComponent,
|
|
218
|
+
queue: QueueItemComponent,
|
|
219
|
+
} = components
|
|
220
|
+
const warn = createWarnOnce()
|
|
221
|
+
const inlineToolNames = collectInlineToolNames(
|
|
222
|
+
interrupts?.tools as Record<string, unknown> | undefined,
|
|
223
|
+
Object.keys(tools ?? {}),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
function Provider(
|
|
227
|
+
handle: Handle<ProviderProps<TOptions>, ComponentsValue<TOptions>>,
|
|
228
|
+
) {
|
|
229
|
+
handle.context.set({
|
|
230
|
+
get chat() {
|
|
231
|
+
return handle.props.chat
|
|
232
|
+
},
|
|
233
|
+
warn,
|
|
234
|
+
inlineToolNames,
|
|
235
|
+
})
|
|
236
|
+
return () => handle.props.children ?? null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function useChatContext(handle: Handle<any>): ChatUIHost<TOptions> {
|
|
240
|
+
const value = handle.context.get(Provider)
|
|
241
|
+
if (!value?.chat) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
'`useChatContext` must be used within `UI.Provider` or `UI.Chat`.',
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
return value.chat
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function MissingInput(_handle: Handle<any>) {
|
|
250
|
+
warn(
|
|
251
|
+
'input',
|
|
252
|
+
'[tanstack-ai-ui] Rendered <Input /> but no `input` component is registered.',
|
|
253
|
+
)
|
|
254
|
+
return () => null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function Chat(handle: Handle<{ chat: ChatUIHost<TOptions> }>) {
|
|
258
|
+
return () => (
|
|
259
|
+
<Provider chat={handle.props.chat}>
|
|
260
|
+
<Layout
|
|
261
|
+
Input={(InputComponent ?? MissingInput) as RemixComp}
|
|
262
|
+
Interrupts={Interrupts}
|
|
263
|
+
Messages={Messages}
|
|
264
|
+
Queue={Queue}
|
|
265
|
+
/>
|
|
266
|
+
</Provider>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function Queue(handle: Handle<any>) {
|
|
271
|
+
return () => {
|
|
272
|
+
const chat = useChatContext(handle)
|
|
273
|
+
if (!QueueItemComponent) return null
|
|
274
|
+
return chat.queue.map((item) => (
|
|
275
|
+
<QueueItemComponent
|
|
276
|
+
key={item.id}
|
|
277
|
+
item={{
|
|
278
|
+
...item,
|
|
279
|
+
cancelQueued: () => {
|
|
280
|
+
chat.cancelQueued(item.id)
|
|
281
|
+
},
|
|
282
|
+
}}
|
|
283
|
+
/>
|
|
284
|
+
))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function Messages(
|
|
289
|
+
handle: Handle<{
|
|
290
|
+
children?: (messages: ChatUIMessages<TOptions>) => RemixNode
|
|
291
|
+
}>,
|
|
292
|
+
) {
|
|
293
|
+
return () => {
|
|
294
|
+
const chat = useChatContext(handle)
|
|
295
|
+
const messages = readMessages(chat)
|
|
296
|
+
const interruptsList = readInterrupts(chat)
|
|
297
|
+
if (typeof handle.props.children === 'function') {
|
|
298
|
+
return handle.props.children(messages)
|
|
299
|
+
}
|
|
300
|
+
return messages.map((message) => (
|
|
301
|
+
<MessageView
|
|
302
|
+
key={message.id}
|
|
303
|
+
inlineToolNames={inlineToolNames}
|
|
304
|
+
interrupts={interruptsList}
|
|
305
|
+
message={message}
|
|
306
|
+
/>
|
|
307
|
+
))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function MessageScope(
|
|
312
|
+
handle: Handle<
|
|
313
|
+
MessageRenderValue<TOptions> & { children?: RemixNode },
|
|
314
|
+
MessageRenderValue<TOptions>
|
|
315
|
+
>,
|
|
316
|
+
) {
|
|
317
|
+
handle.context.set({
|
|
318
|
+
get message() {
|
|
319
|
+
return handle.props.message
|
|
320
|
+
},
|
|
321
|
+
get interrupts() {
|
|
322
|
+
return handle.props.interrupts
|
|
323
|
+
},
|
|
324
|
+
get inlineToolNames() {
|
|
325
|
+
return handle.props.inlineToolNames
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
return () => handle.props.children ?? null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function Parts(handle: Handle<any>) {
|
|
332
|
+
return () => {
|
|
333
|
+
const scope = handle.context.get(MessageScope)
|
|
334
|
+
if (!scope) {
|
|
335
|
+
throw new Error('`Parts` must be rendered by a `message` component.')
|
|
336
|
+
}
|
|
337
|
+
return (
|
|
338
|
+
<AutomaticParts
|
|
339
|
+
inlineToolNames={scope.inlineToolNames}
|
|
340
|
+
interrupts={scope.interrupts}
|
|
341
|
+
message={scope.message}
|
|
342
|
+
/>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function MessageView(
|
|
348
|
+
handle: Handle<{
|
|
349
|
+
message: ChatUIMessages<TOptions>[number]
|
|
350
|
+
interrupts: ReadonlyArray<ChatUIInterrupt>
|
|
351
|
+
inlineToolNames: ReadonlyArray<string>
|
|
352
|
+
children?: (parts: Array<ChatUISelectedPart>) => RemixNode
|
|
353
|
+
}>,
|
|
354
|
+
) {
|
|
355
|
+
return () => {
|
|
356
|
+
const selected = selectMessageUI(handle.props.message, {
|
|
357
|
+
interrupts: handle.props.interrupts,
|
|
358
|
+
inlineToolNames: handle.props.inlineToolNames,
|
|
359
|
+
})
|
|
360
|
+
if (typeof handle.props.children === 'function') {
|
|
361
|
+
return handle.props.children(selected.parts)
|
|
362
|
+
}
|
|
363
|
+
return (
|
|
364
|
+
<MessageScope
|
|
365
|
+
inlineToolNames={handle.props.inlineToolNames}
|
|
366
|
+
interrupts={handle.props.interrupts}
|
|
367
|
+
message={handle.props.message}
|
|
368
|
+
>
|
|
369
|
+
<MessageComponent Parts={Parts} message={handle.props.message} />
|
|
370
|
+
</MessageScope>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function Message(
|
|
376
|
+
handle: Handle<{
|
|
377
|
+
message: ChatUIMessages<TOptions>[number]
|
|
378
|
+
children?: (parts: Array<ChatUISelectedPart>) => RemixNode
|
|
379
|
+
}>,
|
|
380
|
+
) {
|
|
381
|
+
return () => {
|
|
382
|
+
const chat = useChatContext(handle)
|
|
383
|
+
return (
|
|
384
|
+
<MessageView
|
|
385
|
+
children={handle.props.children}
|
|
386
|
+
inlineToolNames={inlineToolNames}
|
|
387
|
+
interrupts={readInterrupts(chat)}
|
|
388
|
+
message={handle.props.message}
|
|
389
|
+
/>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function AutomaticParts(
|
|
395
|
+
handle: Handle<{
|
|
396
|
+
message: ChatUIMessages<TOptions>[number]
|
|
397
|
+
interrupts: ReadonlyArray<ChatUIInterrupt>
|
|
398
|
+
inlineToolNames: ReadonlyArray<string>
|
|
399
|
+
}>,
|
|
400
|
+
) {
|
|
401
|
+
return () => {
|
|
402
|
+
const selected = selectMessageUI(handle.props.message, {
|
|
403
|
+
interrupts: handle.props.interrupts,
|
|
404
|
+
inlineToolNames: handle.props.inlineToolNames,
|
|
405
|
+
})
|
|
406
|
+
return automaticPartsForMessage(selected).map((part, index) => (
|
|
407
|
+
<SelectedPartView
|
|
408
|
+
key={`${handle.props.message.id}-${index}`}
|
|
409
|
+
selected={part}
|
|
410
|
+
/>
|
|
411
|
+
))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function SelectedPartView(handle: Handle<{ selected: ChatUISelectedPart }>) {
|
|
416
|
+
return () => {
|
|
417
|
+
const selected = handle.props.selected
|
|
418
|
+
if (selected.key === 'toolCall') {
|
|
419
|
+
const name = selected.part.name
|
|
420
|
+
const Tool = tools?.[name as ChatUIToolName<TOptions>] as
|
|
421
|
+
| RemixComp<ToolProps<TOptions>>
|
|
422
|
+
| undefined
|
|
423
|
+
if (!Tool) {
|
|
424
|
+
warn(
|
|
425
|
+
`tool:${name}`,
|
|
426
|
+
`[tanstack-ai-ui] Missing tools.${name} component`,
|
|
427
|
+
)
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
return (
|
|
431
|
+
<Tool
|
|
432
|
+
interrupt={selected.interrupt as ToolProps<TOptions>['interrupt']}
|
|
433
|
+
part={selected.part as ToolProps<TOptions>['part']}
|
|
434
|
+
result={selected.result}
|
|
435
|
+
/>
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
const PartComponent = (parts[selected.key] ?? parts.fallback) as
|
|
439
|
+
| RemixComp<PartProps<TOptions>>
|
|
440
|
+
| undefined
|
|
441
|
+
if (!PartComponent) {
|
|
442
|
+
warn(
|
|
443
|
+
`part:${selected.key}`,
|
|
444
|
+
`[tanstack-ai-ui] Missing parts.${selected.key} component`,
|
|
445
|
+
)
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
return (
|
|
449
|
+
<PartComponent part={selected.part as PartProps<TOptions>['part']} />
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function Part(handle: Handle<{ part: MessagePart }>) {
|
|
455
|
+
return () => {
|
|
456
|
+
const chat = useChatContext(handle)
|
|
457
|
+
const selected = selectMessageUI(
|
|
458
|
+
{ id: 'part', role: 'assistant', parts: [handle.props.part] },
|
|
459
|
+
{ interrupts: readInterrupts(chat), inlineToolNames: [] },
|
|
460
|
+
).parts[0]
|
|
461
|
+
if (!selected) return null
|
|
462
|
+
return <SelectedPartView selected={selected} />
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function InterruptView(handle: Handle<{ interrupt: ChatUIInterrupt }>) {
|
|
467
|
+
return () => {
|
|
468
|
+
const Component = resolveInterruptComponent(
|
|
469
|
+
handle.props.interrupt,
|
|
470
|
+
interrupts,
|
|
471
|
+
) as RemixComp<InterruptProps<TOptions>> | undefined
|
|
472
|
+
if (!Component) {
|
|
473
|
+
warn(
|
|
474
|
+
`interrupt:${handle.props.interrupt.id}`,
|
|
475
|
+
`[tanstack-ai-ui] Missing interrupt component for ${handle.props.interrupt.kind}`,
|
|
476
|
+
)
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
return <Component interrupt={handle.props.interrupt} />
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function Interrupts(
|
|
484
|
+
handle: Handle<{
|
|
485
|
+
children?: (interrupts: ReadonlyArray<ChatUIInterrupt>) => RemixNode
|
|
486
|
+
}>,
|
|
487
|
+
) {
|
|
488
|
+
return () => {
|
|
489
|
+
const chat = useChatContext(handle)
|
|
490
|
+
const selected = selectChatUI({
|
|
491
|
+
messages: readMessages(chat),
|
|
492
|
+
interrupts: readInterrupts(chat),
|
|
493
|
+
inlineToolNames,
|
|
494
|
+
})
|
|
495
|
+
if (typeof handle.props.children === 'function') {
|
|
496
|
+
return handle.props.children(selected.interrupts)
|
|
497
|
+
}
|
|
498
|
+
return selected.interrupts.map((interrupt) => (
|
|
499
|
+
<InterruptView key={interrupt.id} interrupt={interrupt} />
|
|
500
|
+
))
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
Chat,
|
|
506
|
+
Provider,
|
|
507
|
+
Queue,
|
|
508
|
+
Messages,
|
|
509
|
+
Message,
|
|
510
|
+
Part,
|
|
511
|
+
Interrupts,
|
|
512
|
+
Interrupt: InterruptView,
|
|
513
|
+
useChatContext,
|
|
514
|
+
Input: InputComponent,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Handle } from 'remix/ui'
|
|
2
|
+
|
|
3
|
+
export interface TextPartProps {
|
|
4
|
+
content: string
|
|
5
|
+
role?: 'user' | 'assistant' | 'system'
|
|
6
|
+
class?: string
|
|
7
|
+
userClass?: string
|
|
8
|
+
assistantClass?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TextPart(handle: Handle<TextPartProps>) {
|
|
12
|
+
return () => {
|
|
13
|
+
const roleClass =
|
|
14
|
+
handle.props.role === 'user'
|
|
15
|
+
? handle.props.userClass
|
|
16
|
+
: handle.props.role === 'assistant'
|
|
17
|
+
? handle.props.assistantClass
|
|
18
|
+
: undefined
|
|
19
|
+
const combinedClass = [handle.props.class, roleClass]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join(' ')
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div class={combinedClass || undefined} data-part-type="text">
|
|
25
|
+
{handle.props.content}
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { on } from 'remix/ui'
|
|
2
|
+
import type { Handle } from 'remix/ui'
|
|
3
|
+
|
|
4
|
+
export interface ThinkingPartProps {
|
|
5
|
+
content: string
|
|
6
|
+
class?: string
|
|
7
|
+
isComplete?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ThinkingPart(handle: Handle<ThinkingPartProps>) {
|
|
11
|
+
let collapsed = false
|
|
12
|
+
let sawComplete = false
|
|
13
|
+
|
|
14
|
+
return () => {
|
|
15
|
+
if (handle.props.isComplete && !sawComplete) {
|
|
16
|
+
collapsed = true
|
|
17
|
+
sawComplete = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
class={handle.props.class || undefined}
|
|
23
|
+
data-part-content
|
|
24
|
+
data-part-type="thinking"
|
|
25
|
+
>
|
|
26
|
+
<button
|
|
27
|
+
aria-expanded={!collapsed}
|
|
28
|
+
aria-label={collapsed ? 'Expand thinking' : 'Collapse thinking'}
|
|
29
|
+
mix={[
|
|
30
|
+
on('click', () => {
|
|
31
|
+
collapsed = !collapsed
|
|
32
|
+
void handle.update()
|
|
33
|
+
}),
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
<span>{collapsed ? '>' : 'v'}</span>
|
|
37
|
+
<span>Thinking...</span>
|
|
38
|
+
{handle.props.isComplete ? <span>(complete)</span> : null}
|
|
39
|
+
</button>
|
|
40
|
+
{collapsed ? null : <div>{handle.props.content}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { on } from 'remix/ui'
|
|
2
|
+
import type { Handle, RemixNode } from 'remix/ui'
|
|
3
|
+
import { useChatContext } from './chat.tsx'
|
|
4
|
+
|
|
5
|
+
export interface ToolApprovalRenderProps {
|
|
6
|
+
toolName: string
|
|
7
|
+
input: unknown
|
|
8
|
+
onApprove: () => void
|
|
9
|
+
onDeny: () => void
|
|
10
|
+
hasResponded: boolean
|
|
11
|
+
approved?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`. Removed in 1.0.0. */
|
|
15
|
+
export interface ToolApprovalProps {
|
|
16
|
+
toolCallId: string
|
|
17
|
+
toolName: string
|
|
18
|
+
input: unknown
|
|
19
|
+
approval: {
|
|
20
|
+
id: string
|
|
21
|
+
needsApproval: boolean
|
|
22
|
+
approved?: boolean
|
|
23
|
+
}
|
|
24
|
+
class?: string
|
|
25
|
+
children?: (props: ToolApprovalRenderProps) => RemixNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`.
|
|
30
|
+
* Removed in 1.0.0.
|
|
31
|
+
*/
|
|
32
|
+
export function ToolApproval(handle: Handle<ToolApprovalProps>) {
|
|
33
|
+
return () => {
|
|
34
|
+
const { addToolApprovalResponse } = useChatContext(handle)
|
|
35
|
+
const approval = handle.props.approval
|
|
36
|
+
|
|
37
|
+
function onApprove() {
|
|
38
|
+
void addToolApprovalResponse({ id: approval.id, approved: true })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function onDeny() {
|
|
42
|
+
void addToolApprovalResponse({ id: approval.id, approved: false })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hasResponded = approval.approved !== undefined
|
|
46
|
+
const renderProps: ToolApprovalRenderProps = {
|
|
47
|
+
toolName: handle.props.toolName,
|
|
48
|
+
input: handle.props.input,
|
|
49
|
+
onApprove,
|
|
50
|
+
onDeny,
|
|
51
|
+
hasResponded,
|
|
52
|
+
approved: approval.approved,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof handle.props.children === 'function') {
|
|
56
|
+
return handle.props.children(renderProps)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (hasResponded) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
class={handle.props.class}
|
|
63
|
+
data-approval-status={approval.approved ? 'approved' : 'denied'}
|
|
64
|
+
data-tool-approval
|
|
65
|
+
>
|
|
66
|
+
{approval.approved ? 'Approved' : 'Denied'}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
class={handle.props.class}
|
|
74
|
+
data-approval-status="pending"
|
|
75
|
+
data-tool-approval
|
|
76
|
+
>
|
|
77
|
+
<div data-approval-header>
|
|
78
|
+
<strong>{handle.props.toolName}</strong> requires approval
|
|
79
|
+
</div>
|
|
80
|
+
<div data-approval-input>
|
|
81
|
+
<pre>{JSON.stringify(handle.props.input, null, 2)}</pre>
|
|
82
|
+
</div>
|
|
83
|
+
<div data-approval-actions>
|
|
84
|
+
<button data-approval-approve mix={[on('click', onApprove)]}>
|
|
85
|
+
Approve
|
|
86
|
+
</button>
|
|
87
|
+
<button data-approval-deny mix={[on('click', onDeny)]}>
|
|
88
|
+
Deny
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|