@tanstack/ai-react 0.22.3 → 0.23.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.
Files changed (60) hide show
  1. package/README.md +11 -15
  2. package/dist/esm/chat-ui/chat-input.d.ts +57 -0
  3. package/dist/esm/chat-ui/chat-input.js +124 -0
  4. package/dist/esm/chat-ui/chat-input.js.map +1 -0
  5. package/dist/esm/chat-ui/chat-message.d.ts +91 -0
  6. package/dist/esm/chat-ui/chat-message.js +157 -0
  7. package/dist/esm/chat-ui/chat-message.js.map +1 -0
  8. package/dist/esm/chat-ui/chat-messages.d.ts +33 -0
  9. package/dist/esm/chat-ui/chat-messages.js +44 -0
  10. package/dist/esm/chat-ui/chat-messages.js.map +1 -0
  11. package/dist/esm/chat-ui/chat.d.ts +53 -0
  12. package/dist/esm/chat-ui/chat.js +57 -0
  13. package/dist/esm/chat-ui/chat.js.map +1 -0
  14. package/dist/esm/chat-ui/create-chat-hook.d.ts +48 -0
  15. package/dist/esm/chat-ui/create-chat-hook.js +57 -0
  16. package/dist/esm/chat-ui/create-chat-hook.js.map +1 -0
  17. package/dist/esm/chat-ui/create-ui-contexts.d.ts +29 -0
  18. package/dist/esm/chat-ui/create-ui-contexts.js +36 -0
  19. package/dist/esm/chat-ui/create-ui-contexts.js.map +1 -0
  20. package/dist/esm/chat-ui/create-ui.d.ts +153 -0
  21. package/dist/esm/chat-ui/create-ui.js +294 -0
  22. package/dist/esm/chat-ui/create-ui.js.map +1 -0
  23. package/dist/esm/chat-ui/markdown-plugins.d.ts +15 -0
  24. package/dist/esm/chat-ui/markdown-plugins.js +28 -0
  25. package/dist/esm/chat-ui/markdown-plugins.js.map +1 -0
  26. package/dist/esm/chat-ui/text-part.d.ts +57 -0
  27. package/dist/esm/chat-ui/text-part.js +46 -0
  28. package/dist/esm/chat-ui/text-part.js.map +1 -0
  29. package/dist/esm/chat-ui/thinking-part.d.ts +41 -0
  30. package/dist/esm/chat-ui/thinking-part.js +73 -0
  31. package/dist/esm/chat-ui/thinking-part.js.map +1 -0
  32. package/dist/esm/chat-ui/tool-approval.d.ts +52 -0
  33. package/dist/esm/chat-ui/tool-approval.js +83 -0
  34. package/dist/esm/chat-ui/tool-approval.js.map +1 -0
  35. package/dist/esm/chat-ui/tool-result-content.d.ts +18 -0
  36. package/dist/esm/chat-ui/tool-result-content.js +19 -0
  37. package/dist/esm/chat-ui/tool-result-content.js.map +1 -0
  38. package/dist/esm/create-chat-hook.d.ts +35 -0
  39. package/dist/esm/create-chat-hook.js +37 -0
  40. package/dist/esm/create-chat-hook.js.map +1 -0
  41. package/dist/esm/index.d.ts +1 -0
  42. package/dist/esm/index.js +2 -1
  43. package/dist/esm/ui.d.ts +10 -0
  44. package/dist/esm/ui.js +11 -0
  45. package/package.json +18 -4
  46. package/src/chat-ui/chat-input.tsx +177 -0
  47. package/src/chat-ui/chat-message.tsx +271 -0
  48. package/src/chat-ui/chat-messages.tsx +89 -0
  49. package/src/chat-ui/chat.tsx +98 -0
  50. package/src/chat-ui/create-chat-hook.tsx +103 -0
  51. package/src/chat-ui/create-ui-contexts.tsx +57 -0
  52. package/src/chat-ui/create-ui.tsx +761 -0
  53. package/src/chat-ui/markdown-plugins.ts +48 -0
  54. package/src/chat-ui/text-part.tsx +97 -0
  55. package/src/chat-ui/thinking-part.tsx +83 -0
  56. package/src/chat-ui/tool-approval.tsx +132 -0
  57. package/src/chat-ui/tool-result-content.ts +32 -0
  58. package/src/create-chat-hook.ts +67 -0
  59. package/src/index.ts +1 -0
  60. package/src/ui.ts +43 -0
@@ -0,0 +1,48 @@
1
+ import remarkGfm from 'remark-gfm'
2
+ import rehypeRaw from 'rehype-raw'
3
+ import rehypeSanitize from 'rehype-sanitize'
4
+ import rehypeHighlight from 'rehype-highlight'
5
+ import type { Options as ReactMarkdownOptions } from 'react-markdown'
6
+
7
+ export type PluggableList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
8
+
9
+ export const DEFAULT_REMARK_PLUGINS: PluggableList = [remarkGfm]
10
+ export const DEFAULT_REHYPE_PLUGINS_BEFORE_USER: PluggableList = [
11
+ rehypeRaw,
12
+ rehypeHighlight,
13
+ ]
14
+ export const DEFAULT_REHYPE_PLUGINS_AFTER_USER: PluggableList = [rehypeSanitize]
15
+
16
+ export interface ResolveMarkdownPluginsOptions {
17
+ remarkPlugins?: PluggableList
18
+ rehypePlugins?: PluggableList
19
+ disableDefaultPlugins?: boolean
20
+ }
21
+
22
+ export interface ResolvedMarkdownPlugins {
23
+ remarkPlugins: PluggableList
24
+ rehypePlugins: PluggableList
25
+ }
26
+
27
+ export function resolveMarkdownPlugins(
28
+ options: ResolveMarkdownPluginsOptions,
29
+ ): ResolvedMarkdownPlugins {
30
+ const userRemark = options.remarkPlugins ?? []
31
+ const userRehype = options.rehypePlugins ?? []
32
+
33
+ if (options.disableDefaultPlugins) {
34
+ return {
35
+ remarkPlugins: [...userRemark],
36
+ rehypePlugins: [...userRehype],
37
+ }
38
+ }
39
+
40
+ return {
41
+ remarkPlugins: [...DEFAULT_REMARK_PLUGINS, ...userRemark],
42
+ rehypePlugins: [
43
+ ...DEFAULT_REHYPE_PLUGINS_BEFORE_USER,
44
+ ...userRehype,
45
+ ...DEFAULT_REHYPE_PLUGINS_AFTER_USER,
46
+ ],
47
+ }
48
+ }
@@ -0,0 +1,97 @@
1
+ import ReactMarkdown from 'react-markdown'
2
+ import { resolveMarkdownPlugins } from './markdown-plugins'
3
+ import type { Components } from 'react-markdown'
4
+ import type { PluggableList } from './markdown-plugins'
5
+
6
+ export interface TextPartProps {
7
+ /** The text content to render */
8
+ content: string
9
+ /** The role of the message (user, assistant, or system) - optional for standalone use */
10
+ role?: 'user' | 'assistant' | 'system'
11
+ /** Base className applied to all text parts */
12
+ className?: string
13
+ /** Additional className for user messages */
14
+ userClassName?: string
15
+ /** Additional className for assistant messages (also used for system messages) */
16
+ assistantClassName?: string
17
+ /**
18
+ * Additional remark plugins, appended after the defaults
19
+ * (or replacing them when `disableDefaultPlugins` is true).
20
+ */
21
+ remarkPlugins?: PluggableList
22
+ /**
23
+ * Additional rehype plugins. Inserted between the built-in
24
+ * `rehypeRaw`/`rehypeHighlight` and the trailing `rehypeSanitize`
25
+ * so sanitization always runs last. When `disableDefaultPlugins`
26
+ * is true, replaces the entire chain.
27
+ */
28
+ rehypePlugins?: PluggableList
29
+ /** react-markdown `components` overrides (e.g. custom `a`, `code`). */
30
+ components?: Components
31
+ /**
32
+ * Drop the built-in plugin defaults entirely. The consumer becomes
33
+ * responsible for syntax highlighting, GFM, raw HTML handling, and
34
+ * sanitization. Use with care — disabling defaults removes the
35
+ * built-in XSS sanitizer.
36
+ */
37
+ disableDefaultPlugins?: boolean
38
+ }
39
+
40
+ /**
41
+ * TextPart component - renders markdown text with syntax highlighting.
42
+ *
43
+ * @example Standalone usage
44
+ * ```tsx
45
+ * <TextPart
46
+ * content="Hello **world**!"
47
+ * role="user"
48
+ * className="p-4 rounded"
49
+ * userClassName="bg-blue-500"
50
+ * assistantClassName="bg-gray-500"
51
+ * />
52
+ * ```
53
+ *
54
+ * @example Add a markdown plugin (e.g. CJK bold/emphasis support)
55
+ * ```tsx
56
+ * import remarkCjkFriendly from 'remark-cjk-friendly'
57
+ *
58
+ * <TextPart content={content} remarkPlugins={[remarkCjkFriendly]} />
59
+ * ```
60
+ */
61
+ export function TextPart({
62
+ content,
63
+ role,
64
+ className = '',
65
+ userClassName = '',
66
+ assistantClassName = '',
67
+ remarkPlugins,
68
+ rehypePlugins,
69
+ components,
70
+ disableDefaultPlugins,
71
+ }: TextPartProps) {
72
+ const roleClassName =
73
+ role === 'user'
74
+ ? userClassName
75
+ : role === 'assistant'
76
+ ? assistantClassName
77
+ : ''
78
+ const combinedClassName = [className, roleClassName].filter(Boolean).join(' ')
79
+
80
+ const resolved = resolveMarkdownPlugins({
81
+ remarkPlugins,
82
+ rehypePlugins,
83
+ disableDefaultPlugins,
84
+ })
85
+
86
+ return (
87
+ <div className={combinedClassName || undefined}>
88
+ <ReactMarkdown
89
+ remarkPlugins={resolved.remarkPlugins}
90
+ rehypePlugins={resolved.rehypePlugins}
91
+ components={components}
92
+ >
93
+ {content}
94
+ </ReactMarkdown>
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,83 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export interface ThinkingPartProps {
4
+ /** The thinking content to render */
5
+ content: string
6
+ /** Base className applied to thinking parts */
7
+ className?: string
8
+ /** Whether thinking is complete (has text content after) */
9
+ isComplete?: boolean
10
+ }
11
+
12
+ /**
13
+ * ThinkingPart component - renders thinking/reasoning content
14
+ *
15
+ * This component displays the model's internal reasoning process,
16
+ * typically shown in a collapsed or expandable format to distinguish
17
+ * it from the final response. It automatically collapses when thinking
18
+ * is complete.
19
+ *
20
+ * @example Standalone usage
21
+ * ```tsx
22
+ * <ThinkingPart
23
+ * content="Let me think about this step by step..."
24
+ * className="p-4 rounded bg-gray-100"
25
+ * />
26
+ * ```
27
+ *
28
+ * @example Usage in partRenderers
29
+ * ```tsx
30
+ * <ChatMessage
31
+ * message={message}
32
+ * partRenderers={{
33
+ * thinking: ({ content, isComplete }) => (
34
+ * <ThinkingPart
35
+ * content={content}
36
+ * isComplete={isComplete}
37
+ * className="px-5 py-3 rounded-2xl bg-gray-800/50"
38
+ * />
39
+ * )
40
+ * }}
41
+ * />
42
+ * ```
43
+ */
44
+ export function ThinkingPart({
45
+ content,
46
+ className = '',
47
+ isComplete = false,
48
+ }: ThinkingPartProps) {
49
+ const [isCollapsed, setIsCollapsed] = useState(false)
50
+
51
+ // Auto-collapse when thinking completes
52
+ useEffect(() => {
53
+ if (isComplete) {
54
+ setIsCollapsed(true)
55
+ }
56
+ }, [isComplete])
57
+
58
+ return (
59
+ <div
60
+ className={className || undefined}
61
+ data-part-type="thinking"
62
+ data-part-content
63
+ >
64
+ <button
65
+ onClick={() => setIsCollapsed(!isCollapsed)}
66
+ className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-300 transition-colors mb-2"
67
+ aria-expanded={!isCollapsed}
68
+ aria-label={isCollapsed ? 'Expand thinking' : 'Collapse thinking'}
69
+ >
70
+ <span className="text-xs">{isCollapsed ? '▶' : '▼'}</span>
71
+ <span className="italic">💭 Thinking...</span>
72
+ {isComplete && (
73
+ <span className="text-xs text-gray-500">(complete)</span>
74
+ )}
75
+ </button>
76
+ {!isCollapsed && (
77
+ <div className="text-gray-300 whitespace-pre-wrap font-mono text-sm">
78
+ {content}
79
+ </div>
80
+ )}
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,132 @@
1
+ import { useChatContext } from './chat'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export interface ToolApprovalProps {
5
+ /** Tool call ID */
6
+ toolCallId: string
7
+ /** Tool name */
8
+ toolName: string
9
+ /** Parsed tool arguments/input */
10
+ input: any
11
+ /** Approval metadata */
12
+ approval: {
13
+ id: string
14
+ needsApproval: boolean
15
+ approved?: boolean
16
+ }
17
+ /** CSS class name */
18
+ className?: string
19
+ /** Custom render prop */
20
+ children?: (props: ToolApprovalRenderProps) => ReactNode
21
+ }
22
+
23
+ export interface ToolApprovalRenderProps {
24
+ /** Tool name */
25
+ toolName: string
26
+ /** Parsed input */
27
+ input: any
28
+ /** Approve the tool call */
29
+ onApprove: () => void
30
+ /** Deny the tool call */
31
+ onDeny: () => void
32
+ /** Whether user has responded */
33
+ hasResponded: boolean
34
+ /** User's decision (if responded) */
35
+ approved?: boolean
36
+ }
37
+
38
+ /**
39
+ * @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`.
40
+ * Deprecated in 0.9.0. Removed in 1.0.0.
41
+ *
42
+ * Tool approval component - renders approve/deny buttons for tools that need approval
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * {part.approval && (
47
+ * <ToolApproval
48
+ * toolCallId={part.id}
49
+ * toolName={part.name}
50
+ * input={JSON.parse(part.arguments)}
51
+ * approval={part.approval}
52
+ * />
53
+ * )}
54
+ * ```
55
+ */
56
+ export function ToolApproval({
57
+ toolCallId: _,
58
+ toolName,
59
+ input,
60
+ approval,
61
+ className,
62
+ children,
63
+ }: ToolApprovalProps) {
64
+ const { addToolApprovalResponse } = useChatContext()
65
+
66
+ const handleApprove = () => {
67
+ void addToolApprovalResponse({
68
+ id: approval.id,
69
+ approved: true,
70
+ })
71
+ }
72
+
73
+ const handleDeny = () => {
74
+ void addToolApprovalResponse({
75
+ id: approval.id,
76
+ approved: false,
77
+ })
78
+ }
79
+
80
+ const hasResponded = approval.approved !== undefined
81
+
82
+ const renderProps: ToolApprovalRenderProps = {
83
+ toolName,
84
+ input,
85
+ onApprove: handleApprove,
86
+ onDeny: handleDeny,
87
+ hasResponded,
88
+ approved: approval.approved,
89
+ }
90
+
91
+ // Render prop pattern
92
+ if (children) {
93
+ return <>{children(renderProps)}</>
94
+ }
95
+
96
+ // Already responded - show decision
97
+ if (hasResponded) {
98
+ return (
99
+ <div
100
+ className={className}
101
+ data-tool-approval
102
+ data-approval-status={approval.approved ? 'approved' : 'denied'}
103
+ >
104
+ {approval.approved ? '✓ Approved' : '✗ Denied'}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ // Default approval UI
110
+ return (
111
+ <div
112
+ className={className}
113
+ data-tool-approval
114
+ data-approval-status="pending"
115
+ >
116
+ <div data-approval-header>
117
+ <strong>{toolName}</strong> requires approval
118
+ </div>
119
+ <div data-approval-input>
120
+ <pre>{JSON.stringify(input, null, 2)}</pre>
121
+ </div>
122
+ <div data-approval-actions>
123
+ <button onClick={handleApprove} data-approval-approve>
124
+ Approve
125
+ </button>
126
+ <button onClick={handleDeny} data-approval-deny>
127
+ Deny
128
+ </button>
129
+ </div>
130
+ </div>
131
+ )
132
+ }
@@ -0,0 +1,32 @@
1
+ import type { UIMessage } from '../types'
2
+
3
+ type ToolResultPart = Extract<
4
+ UIMessage['parts'][number],
5
+ { type: 'tool-result' }
6
+ >
7
+
8
+ /** `string | Array<ContentPart>` — a tool result's raw content. */
9
+ type ToolResultContent = ToolResultPart['content']
10
+
11
+ type ContentPartItem = Exclude<ToolResultContent, string>[number]
12
+
13
+ /**
14
+ * Reduce a tool-result part's `content` to a plain string for rendering.
15
+ *
16
+ * Tool results carry `string | Array<ContentPart>` (multimodal results are
17
+ * normalized to an array of content parts upstream). The `ChatMessage`
18
+ * renderers operate on plain strings, so array content is flattened to the
19
+ * concatenation of its text parts. Non-text parts (image, audio, video,
20
+ * document) have no string form here and are skipped — matching the
21
+ * text-extraction behavior used elsewhere for `string | Array<ContentPart>`.
22
+ */
23
+ export function toolResultContentToString(content: ToolResultContent): string {
24
+ if (typeof content === 'string') return content
25
+ return content
26
+ .filter(
27
+ (part): part is Extract<ContentPartItem, { type: 'text' }> =>
28
+ part.type === 'text',
29
+ )
30
+ .map((part) => part.content)
31
+ .join('')
32
+ }
@@ -0,0 +1,67 @@
1
+ import { useChat as useUnboundChat } from './use-chat'
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 } from './types'
9
+
10
+ type ChatHookOverrides<
11
+ TTools extends ReadonlyArray<AnyClientTool>,
12
+ TSchema extends SchemaInput | undefined,
13
+ TContext,
14
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
15
+ > = {
16
+ threadId?: string
17
+ live?: boolean
18
+ forwardedProps?: Record<string, any>
19
+ body?: Record<string, any>
20
+ initialMessages?: UseChatOptions<
21
+ TTools,
22
+ TSchema,
23
+ TContext,
24
+ TInterrupts
25
+ >['initialMessages']
26
+ }
27
+
28
+ /**
29
+ * Bind chat options once at module scope. The returned `useChat` hook
30
+ * creates a chat instance from those options.
31
+ *
32
+ * Pass per-call overrides for instance keys such as `threadId`,
33
+ * `initialMessages`, `live`, and `forwardedProps`. Do not change `tools`,
34
+ * `interrupts`, or `outputSchema` here. Those stay on the factory options.
35
+ *
36
+ * Rename the hook at the call site if you already import `useChat` from
37
+ * `@tanstack/ai-react`: `const { useChat: useAppChat } = createChatHook(chatOptions)`.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const { useChat } = createChatHook(chatOptions)
42
+ *
43
+ * function Support() {
44
+ * const chat = useChat({ threadId: 'support-1' })
45
+ * return <UI.Chat chat={chat} />
46
+ * }
47
+ * ```
48
+ */
49
+ export function createChatHook<
50
+ const TTools extends ReadonlyArray<AnyClientTool> = any,
51
+ TSchema extends SchemaInput | undefined = undefined,
52
+ TContext = InferredClientContext<TTools>,
53
+ const TInterrupts extends ReadonlyArray<
54
+ InterruptDefinition<any, any, any, any>
55
+ > = readonly [],
56
+ >(options: UseChatOptions<TTools, TSchema, TContext, TInterrupts>) {
57
+ function useChat(
58
+ overrides?: ChatHookOverrides<TTools, TSchema, TContext, TInterrupts>,
59
+ ) {
60
+ if (!overrides) {
61
+ return useUnboundChat(options)
62
+ }
63
+ return useUnboundChat({ ...options, ...overrides })
64
+ }
65
+
66
+ return { useChat }
67
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { useChat } from './use-chat'
2
+ export { createChatHook } from './create-chat-hook'
2
3
  export { useByok } from './use-byok'
3
4
  export { useRealtimeChat } from './use-realtime-chat'
4
5
  export { useMcpAppBridge } from './use-mcp-app-bridge'
package/src/ui.ts ADDED
@@ -0,0 +1,43 @@
1
+ // Barrel entry for the `@tanstack/ai-react/ui` subpath. The JSX
2
+ // implementation lives under `./chat-ui`; this `.ts` re-export exists so
3
+ // kiira's dist->src resolution (which maps `dist/esm/ui.d.ts` to `src/ui.ts`,
4
+ // never a directory index) can type-check docs snippets that import this
5
+ // subpath.
6
+ export {
7
+ createChatUI,
8
+ type ChatUIComponents,
9
+ type ChatUIFactoryConfig,
10
+ type ChatUIHost,
11
+ type ChatUIQueueItem,
12
+ type InputProps,
13
+ type InterruptProps,
14
+ type LayoutProps,
15
+ type MessageProps,
16
+ type PartProps,
17
+ type QueueProps,
18
+ type ToolProps,
19
+ } from './chat-ui/create-ui'
20
+ export { createChatHook } from './chat-ui/create-chat-hook'
21
+ export {
22
+ createChatHookContexts,
23
+ type ChatUIContexts,
24
+ } from './chat-ui/create-ui-contexts'
25
+ export { Chat, useChatContext, type ChatProps } from './chat-ui/chat'
26
+ export { ChatMessages, type ChatMessagesProps } from './chat-ui/chat-messages'
27
+ export {
28
+ ChatMessage,
29
+ type ChatMessageProps,
30
+ type ToolCallRenderProps,
31
+ } from './chat-ui/chat-message'
32
+ export {
33
+ ChatInput,
34
+ type ChatInputProps,
35
+ type ChatInputRenderProps,
36
+ } from './chat-ui/chat-input'
37
+ export {
38
+ ToolApproval,
39
+ type ToolApprovalProps,
40
+ type ToolApprovalRenderProps,
41
+ } from './chat-ui/tool-approval'
42
+ export { TextPart, type TextPartProps } from './chat-ui/text-part'
43
+ export { ThinkingPart, type ThinkingPartProps } from './chat-ui/thinking-part'