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