diff --git a/.changeset/typed-headless-chat-ui.md b/.changeset/typed-headless-chat-ui.md new file mode 100644 index 0000000000..db5dfc9eec --- /dev/null +++ b/.changeset/typed-headless-chat-ui.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react-ui': minor +'@tanstack/ai-solid-ui': minor +'@tanstack/ai-vue-ui': minor +'@tanstack/ai-svelte-ui': minor +--- + +Add typed headless `createChatUI()` adapters. Chat options control the types of message parts, tools, structured output, and interrupts. `defineComponents` requires a component for every tool name and every registered interrupt id. `InterruptProps` pins a tool approval or a registered generic interrupt. Old Chat orchestration stays importable and deprecated until 1.0. diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 59af3902b1..e5aad09ddc 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -15,6 +15,8 @@ keywords: Framework-agnostic headless client for managing chat state and streaming. +For typed headless chat UI types and selectors, import `@tanstack/ai-client/ui`. See [Custom Chat UI Adapters](../ui/custom-adapters). + ## Installation ```bash diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index 7d86fa3839..ff4fafe768 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -13,6 +13,8 @@ keywords: --- React hooks for TanStack AI, providing convenient React bindings for the headless client. + +For a typed headless chat UI, see [React Chat UI](../ui/react) and [Migrate to createChatUI](../migration/create-ui). For React Native, the documented support surface is narrow: `useChat` with chat connection adapters. React DOM-specific UI packages and TanStack AI devtools UI are not part of the React Native support surface. diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index f495993822..3fd64dfb6a 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -15,6 +15,8 @@ keywords: SolidJS primitives for TanStack AI, providing convenient SolidJS bindings for the headless client. +For a typed headless chat UI, see [Solid Chat UI](../ui/solid) and [Migrate to createChatUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 60488ce058..a59e25da52 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -15,6 +15,8 @@ keywords: Svelte 5 bindings for TanStack AI, providing reactive factory functions for the headless client using Svelte runes. +For a typed headless chat UI, see [Svelte Chat UI](../ui/svelte) and [Migrate to createChatUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index 6606cf4444..cd46cf22e6 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -15,6 +15,8 @@ keywords: Vue composables for TanStack AI, providing convenient Vue 3 bindings for the headless client. +For a typed headless chat UI, see [Vue Chat UI](../ui/vue) and [Migrate to createChatUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/config.json b/docs/config.json index 46eb82d575..f02ac79ee8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -211,6 +211,42 @@ } ] }, + { + "label": "UI", + "tab": "guides", + "children": [ + { + "label": "React", + "to": "ui/react", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-27" + }, + { + "label": "Solid", + "to": "ui/solid", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-27" + }, + { + "label": "Vue", + "to": "ui/vue", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-27" + }, + { + "label": "Svelte", + "to": "ui/svelte", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-27" + }, + { + "label": "Custom Adapters", + "to": "ui/custom-adapters", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + } + ] + }, { "label": "Interrupts", "tab": "guides", @@ -843,6 +879,12 @@ "addedAt": "2026-04-15", "updatedAt": "2026-07-22" }, + { + "label": "createChatUI", + "to": "migration/create-ui", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-27" + }, { "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai", @@ -875,19 +917,19 @@ "label": "@tanstack/ai-client", "to": "api/ai-client", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "@tanstack/ai-react", "to": "api/ai-react", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "@tanstack/ai-solid", "to": "api/ai-solid", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "@tanstack/ai-preact", @@ -899,13 +941,13 @@ "label": "@tanstack/ai-vue", "to": "api/ai-vue", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "@tanstack/ai-svelte", "to": "api/ai-svelte", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "@tanstack/ai-angular", diff --git a/docs/migration/create-ui.md b/docs/migration/create-ui.md new file mode 100644 index 0000000000..6a6836231f --- /dev/null +++ b/docs/migration/create-ui.md @@ -0,0 +1,121 @@ +--- +title: Migrate to createChatUI +id: migrate-create-ui +order: 5 +description: "Move chat-state ownership out of the old Chat component and onto createChatUI with a typed component map." +keywords: + - tanstack ai + - createChatUI + - migration + - deprecation +--- + +The old `Chat` component owned chat state and lost configured types. `createChatUI` keeps types from your `chatOptions` and leaves `useChat` in your app. + +This is a semantic migration. There is no codemod. + +## What changes + +1. You call `useChat` or `createChat` yourself. +2. You supply every visible component. +3. Tool inputs stay optional while they stream. +4. Tool approvals come from `chat.interrupts`. +5. Unknown runtime keys can use a fallback or render nothing. +6. `createChatUI()` must run at module scope so identity stays stable. + +## Why + +The old APIs drop configured types, keep unused properties, use a deprecated approval path, cover only part of the message protocol, and own chat state. Two orchestration models duplicate fixes. + +## Minimum versions + +- `@tanstack/ai-react-ui` 0.9.0 +- `@tanstack/ai-solid-ui` 0.8.0 +- `@tanstack/ai-vue-ui` 0.3.0 +- `@tanstack/ai-svelte-ui` 0.2.0 + +Old orchestration exports stay importable until each package's `1.0.0`. `TextPart` and `ThinkingPart` stay supported. + +## Before + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { Chat, ChatMessages, ChatInput } from '@tanstack/ai-react-ui' + +const connection = fetchServerSentEvents('/api/chat') + +export function OldChat() { + return ( + + + + + ) +} +``` + +## After + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInput }) => ( +
+ {renderMessages()} + {renderInput()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + input: () => { + const chat = UI.useChat() + return ( +
{ + event.preventDefault() + const field = event.currentTarget.elements.namedItem('message') + if (!(field instanceof HTMLInputElement)) return + const text = field.value.trim() + if (!text) return + field.value = '' + void chat.sendMessage(text) + }} + > + +
+ ) + }, + parts: { fallback: () => null }, +}) + +export function NewChat() { + const chat = useChat(chatOptions) + return +} +``` + +## Steps + +1. Move `connection`, `tools`, and `interrupts` into a module-level `chatOptions` object. +2. Call `createChatUI(chatOptions)` next to that object. +3. Call `useChat(chatOptions)` in the screen component. +4. Define `layout`, `message`, `parts`, `tools`, and `interrupts` in `defineComponents`. +5. Replace `` with ``. + +## Gotchas + +- A shared `chatOptions` variable does not need `as const`. +- A mapped tool can read `interrupt` and render the approval itself. That approval stays off the list. A component on `interrupts.tools` uses the list. +- Generic interrupts live under `interrupts.generic`: a registered id such as `choosePlan`, plus `fallback`. Unbound interrupts use `fallback`. +- TypeScript requires a `tools` component for every tool name and an `interrupts.generic` component for every interrupt id. `generic.fallback` is optional. +- Matched `tool-result` parts are hidden in automatic traversal. Unmatched results stay visible. +- Nested providers use the nearest chat instance. + +See the [React UI guide](../ui/react) for a full map. diff --git a/docs/ui/custom-adapters.md b/docs/ui/custom-adapters.md new file mode 100644 index 0000000000..d51a4e8f60 --- /dev/null +++ b/docs/ui/custom-adapters.md @@ -0,0 +1,34 @@ +--- +title: Custom Chat UI Adapters +id: typed-headless-ui-custom-adapters +order: 5 +description: "Build a framework adapter on @tanstack/ai-client/ui. The core is types and selectors only." +keywords: + - tanstack ai + - createChatUI + - custom adapter + - headless ui +--- + +Import `@tanstack/ai-client/ui`. Do not import it from the main client entry. + +The subpath gives you: + +1. `selectChatUI` to match tool results and split list vs inline interrupts +2. `partTypeToKey` to turn `tool-call` into `toolCall` +3. Option types for tools, generic interrupts, and `outputSchema` + +Your adapter owns: + +1. Native components and context +2. Native reactivity +3. Render callbacks, slots, or snippets +4. Development warnings for missing mapped keys + +Do not add default markup. Do not add a new store. The app owns `useChat` or `createChat`. + +Call `selectChatUI({ messages, interrupts, inlineToolNames })`. Automatic traversal skips a `tool-result` only when `matched` is true. Keep unmatched results. + +Warn once per missing runtime key in development. Each build tool detects development mode differently, so the adapter prints the warning. + +See the [React](./react), [Solid](./solid), [Vue](./vue), and [Svelte](./svelte) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, and `defineComponents`. diff --git a/docs/ui/react.md b/docs/ui/react.md new file mode 100644 index 0000000000..2a05727143 --- /dev/null +++ b/docs/ui/react.md @@ -0,0 +1,545 @@ +--- +title: React Chat UI +id: typed-headless-ui-react +order: 1 +description: "Build a typed, headless React chat UI with createChatUI. Your chat options control the types of tools, parts, and interrupts." +keywords: + - tanstack ai + - createChatUI + - react + - headless ui + - useChat + - ToolProps +--- + +Install `@tanstack/ai-react-ui`, then call `createChatUI(chatOptions)` once at module scope. Your app owns `useChat`. The UI only renders. Call `UI.useChat()` inside a mapped component when it needs live chat. That call is the same value you passed into `UI.Chat`. + +You supply every visible component. There is no default markup, style, or copy. + +`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. + +## Server + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const json: unknown = await request.json() + if (typeof json !== 'object' || json === null || !('messages' in json)) { + return new Response('Invalid body', { status: 400 }) + } + const messages = json.messages + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages: Array.isArray(messages) ? messages : [], + }) + return toServerSentEventsResponse(stream) +} +``` + +## Client + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' +import { defineInterrupt, toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const purchaseItem = toolDefinition({ + name: 'purchaseItem', + description: 'Buy an item', + needsApproval: true, + inputSchema: z.object({ item: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), +}).client() + +const choosePlan = defineInterrupt({ + id: 'choosePlan', + payloadSchema: z.object({ title: z.string() }), + responseSchema: z.string(), +}) + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather, purchaseItem], + interrupts: [choosePlan], + outputSchema: z.object({ answer: z.string() }), +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: function Layout({ + renderMessages, + renderInterrupts, + renderInput, + }) { + const chat = UI.useChat() + if (chat.error) return

{chat.error.message}

+ if (chat.isLoading && chat.messages.length === 0) return

Loading

+ if (chat.messages.length === 0) return

Empty

+ return ( +
+ {renderMessages()} + {renderInterrupts()} + {renderInput()} +
+ ) + }, + message: function Message({ message, renderParts }) { + return
{renderParts()}
+ }, + input: function Input() { + const chat = UI.useChat() + return ( +
{ + event.preventDefault() + const form = event.currentTarget + const field = form.elements.namedItem('message') + if (!(field instanceof HTMLInputElement)) return + void chat.sendMessage(field.value) + field.value = '' + }} + > + + +
+ ) + }, + parts: { + text: ({ part }) =>

{part.content}

, + structuredOutput: ({ part }) =>
{part.raw}
, + toolResult: ({ part }) => {String(part.content)}, + fallback: ({ part }) => {part.type}, + }, + tools: { + getWeather: ({ part, result }) => { + if (part.state === 'awaiting-input') return

Waiting

+ if (part.state === 'input-streaming') return

Streaming input

+ if (part.state === 'input-complete') return

{part.input?.city}

+ if (part.state === 'approval-requested') return

Need approval

+ if (part.state === 'approval-responded') return

Responded

+ if (part.state === 'error') return

Error

+ return ( +

+ {part.input?.city}: {String(part.output?.temperature ?? result?.content)} +

+ ) + }, + purchaseItem: ({ part, interrupt }) => ( +
+ {part.input?.item} + {interrupt?.status === 'pending' ? ( + + ) : null} +
+ ), + }, + interrupts: { + generic: { + choosePlan: ({ interrupt }) => ( + + ), + fallback: ({ interrupt }) =>

{interrupt.reason}

, + }, + }, +}) + +export function ChatScreen() { + const chat = useChat(chatOptions) + return +} +``` + +## Type a component in its own file + +A tool map grows fast. Move a tool into its own file and type the props with `ToolProps`. + +`ToolProps` takes your `chatOptions` type and the tool name. Then `part.input` and `part.output` stay exact. + +Part components work the same way. `PartProps` already has a text part. You do not check `part.type`. Use `'structuredOutput'`, `'thinking'`, `'toolResult'`, and the other keys from the `parts` map. `fallback` still sees every part type. + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatUI, type PartProps, type ToolProps } from '@tanstack/ai-react-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +export function WeatherTool({ + part, + result, +}: ToolProps) { + if (part.state === 'awaiting-input') return

Waiting

+ if (part.state === 'input-streaming') return

Streaming input

+ if (part.state === 'error') return

Error

+ return ( +

+ {part.input?.city}: {String(part.output?.temperature ?? result?.content)} +

+ ) +} + +export function TextPart({ part }: PartProps) { + return

{part.content}

+} + +const UI = createChatUI(chatOptions) + +export const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { text: TextPart, fallback: () => null }, + tools: { getWeather: WeatherTool }, +}) +``` + +1. Put `chatOptions` in a shared module. +2. Import `ToolProps` or `PartProps` from `@tanstack/ai-react-ui`. +3. Type the component with `ToolProps` or `PartProps`. +4. Pass that component into `tools.getWeather` or `parts.text`. + +For an interrupt, use `InterruptProps`. Pass a tool name or a registered interrupt id as the second type argument. Then you do not check `interrupt.kind`. + +- A tool approval: `InterruptProps`. Then `interrupt.toolName` is `'purchaseItem'`. +- A registered generic interrupt: `InterruptProps`. Then `interrupt.payload` and `interrupt.resolveInterrupt` match the definition. + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatUI, type InterruptProps } from '@tanstack/ai-react-ui' +import { defineInterrupt } from '@tanstack/ai' +import { z } from 'zod' + +const choosePlan = defineInterrupt({ + id: 'choosePlan', + payloadSchema: z.object({ title: z.string() }), + responseSchema: z.string(), +}) + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + interrupts: [choosePlan], +} + +export function ChoosePlan({ + interrupt, +}: InterruptProps) { + return ( + + ) +} + +const UI = createChatUI(chatOptions) + +export const components = UI.defineComponents({ + layout: ({ renderInterrupts }) => renderInterrupts(), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + interrupts: { + generic: { + choosePlan: ChoosePlan, + }, + }, +}) +``` + +Other prop types from the same package: + +- `LayoutProps` +- `MessageProps` +- `InputProps` +- `PartProps` with a part key such as `'text'` +- `InterruptProps` for tool approvals, registered generic interrupts, and `generic.fallback`. Pass a tool name or interrupt id as the second type argument. + +## Read chat from `UI.useChat()` + +Mapped components do not receive `chat` as a prop. Call `UI.useChat()` inside a component when it needs live chat. That call opts the component into chat re-renders. Nested children can call it too. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createChatUI(chatOptions) + +function StatusLine() { + const chat = UI.useChat() + if (chat.error) return

{chat.error.message}

+ if (chat.isLoading) return

Loading

+ return

{chat.messages.length} messages

+} + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInput }) => ( +
+ + {renderMessages()} + {renderInput()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, +}) + +export function ChatScreen() { + const chat = useChat(chatOptions) + return +} +``` + +Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`. A call outside that tree throws. + +`useChat(chatOptions)` from `@tanstack/ai-react` still owns the state. `UI.useChat()` only reads the instance that you passed into the provider. + +## Tool approvals: inline or list + +A tool with `needsApproval: true` can render its approval in two places. + +### Inline, next to the tool + +Read `interrupt` on the tool. Render the approval in that same component. Do not register `interrupts.tools` for that name. A mapped tool keeps its approval off the list. + +`interrupt` is already the approval for that tool name. You do not check `interrupt.kind`. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI, type ToolProps } from '@tanstack/ai-react-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const purchaseItem = toolDefinition({ + name: 'purchaseItem', + description: 'Buy an item', + needsApproval: true, + inputSchema: z.object({ item: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [purchaseItem], +} + +const UI = createChatUI(chatOptions) + +function PurchaseItem({ + part, + interrupt, +}: ToolProps) { + return ( +
+ {part.input?.item} + {interrupt?.status === 'pending' ? ( + + ) : null} +
+ ) +} + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( +
+ {renderMessages()} + {renderInterrupts()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + tools: { + purchaseItem: PurchaseItem, + }, +}) + +export function InlineApprovalChat() { + const chat = useChat(chatOptions) + return +} +``` + +To split the approval into its own file, type it with `InterruptProps`. Render that component from the tool. + +### List, in `renderInterrupts()` + +Register the approval under `interrupts.tools`. That component appears in the interrupt list. Do not also render `interrupt` on the tool unless you want it in both places. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const purchaseItem = toolDefinition({ + name: 'purchaseItem', + description: 'Buy an item', + needsApproval: true, + inputSchema: z.object({ item: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [purchaseItem], +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( +
+ {renderMessages()} + {renderInterrupts()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + tools: { + purchaseItem: ({ part }) =>
{part.input?.item}
, + }, + interrupts: { + tools: { + purchaseItem: ({ interrupt }) => ( + + ), + }, + }, +}) + +export function ListApprovalChat() { + const chat = useChat(chatOptions) + return +} +``` + +## Generic interrupts + +Generic interrupts always render in the list (`renderInterrupts()` / ``). They never render inside a tool. + +Map them under `interrupts.generic`: + +- A registered id such as `choosePlan`: the component for that definition +- `fallback`: every other list interrupt, including an unknown generic id and an unbound interrupt this chat does not own + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' +import { defineInterrupt } from '@tanstack/ai' +import { z } from 'zod' + +const choosePlan = defineInterrupt({ + id: 'choosePlan', + payloadSchema: z.object({ title: z.string() }), + responseSchema: z.string(), +}) + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + interrupts: [choosePlan], +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderInterrupts }) => renderInterrupts(), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + interrupts: { + generic: { + choosePlan: ({ interrupt }) => ( + + ), + fallback: ({ interrupt }) => + interrupt.kind === 'unbound' ? ( +

Paused elsewhere: {interrupt.reason}

+ ) : ( +

{interrupt.reason}

+ ), + }, + }, +}) + +export function GenericInterruptChat() { + const chat = useChat(chatOptions) + return +} +``` + +You can mix this map with `interrupts.tools` in the same `defineComponents` call. + +## Manual traversal + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, +}) + +export function ManualChat() { + const chat = useChat(chatOptions) + return ( + + + {(messages) => + messages.map((message) => ( + + {(parts) => + parts.map((part, index) => ( + {part.key} + )) + } + + )) + } + + + ) +} +``` + +Unknown runtime tool names warn once in development and render nothing. Add a `parts.fallback` for unknown part types. + +See also [Solid](./solid), [Vue](./vue), [Svelte](./svelte), and [custom adapters](./custom-adapters). diff --git a/docs/ui/solid.md b/docs/ui/solid.md new file mode 100644 index 0000000000..b5fcaeb36e --- /dev/null +++ b/docs/ui/solid.md @@ -0,0 +1,153 @@ +--- +title: Solid Chat UI +id: typed-headless-ui-solid +order: 2 +description: "Build a typed, headless Solid chat UI with createChatUI. Accessors stay tracked. Your app owns useChat." +keywords: + - tanstack ai + - createChatUI + - solid + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-solid-ui`, then call `createChatUI(chatOptions)` once at module scope. Do not destructure reactive props. + +`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. + +The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. + +## Client + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' +import { createChatUI } from '@tanstack/ai-solid-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +const UI = createChatUI(chatOptions) + +const components = UI.defineComponents({ + layout: (props) => ( + <> + {props.renderMessages()} + {props.renderInterrupts()} + {props.renderInput()} + + ), + message: (props) =>
{props.renderParts()}
, + parts: { + fallback: (props) => {props.part.type}, + }, + tools: { + getWeather: (props) => {props.part.input?.city}, + }, +}) + +export function ChatScreen() { + const chat = useChat(chatOptions) + return +} +``` + +## Type a component in its own file + +Use `ToolProps` the same way as React. Keep the `props` object so Solid can track it. + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-solid' +import { createChatUI, type ToolProps } from '@tanstack/ai-solid-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +export function WeatherTool( + props: ToolProps, +) { + return {props.part.input?.city} +} + +const UI = createChatUI(chatOptions) + +export const components = UI.defineComponents({ + layout: (props) => props.renderMessages(), + message: (props) =>
{props.renderParts()}
, + parts: { fallback: () => null }, + tools: { getWeather: WeatherTool }, +}) +``` + +Part components use `PartProps`. Then `part` is already a text part. + +Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. + +Mapped components do not receive `chat` as a prop. Call `UI.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. + +## Read chat from `UI.useChat()` + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' +import { createChatUI } from '@tanstack/ai-solid-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createChatUI(chatOptions) + +function StatusLine() { + const chat = UI.useChat() + return

{chat.messages.length} messages

+} + +const components = UI.defineComponents({ + layout: (props) => ( + <> + + {props.renderMessages()} + + ), + message: (props) =>
{props.renderParts()}
, + parts: { fallback: () => null }, +}) + +export function ChatScreen() { + const chat = useChat(chatOptions) + return +} +``` + +Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`. + +## Interrupts + +Tool approvals sit in the tool when you read `props.interrupt`. Put a component on `interrupts.tools` to send that approval to the list instead. Generic interrupts always sit in the list under `interrupts.generic`: `{ choosePlan, fallback }`. An unbound interrupt uses `fallback`. Branch on `interrupt.kind === 'unbound'` if the copy must differ. + +The full map is on the [React page](./react). + +Manual list: `{(messages) => {messages().length}}`. + +Pass `props.chat`, `props.part`, and `props.renderParts()` without destructure. diff --git a/docs/ui/svelte.md b/docs/ui/svelte.md new file mode 100644 index 0000000000..1fea54dca2 --- /dev/null +++ b/docs/ui/svelte.md @@ -0,0 +1,101 @@ +--- +title: Svelte Chat UI +id: typed-headless-ui-svelte +order: 4 +description: "Build a typed, headless Svelte 5 chat UI with createChatUI, snippets, and static components." +keywords: + - tanstack ai + - createChatUI + - svelte + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-svelte-ui`. Call `createChatUI(chatOptions)` once. Pass `{ui}`, `{chat}`, and `{components}` into `UIChat`. + +`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. + +The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. + +## Client + +```svelte + + + +``` + +`Layout.svelte` receives snippets `messages`, `interrupts`, and `input`. `Message.svelte` receives snippet `parts`. A tool with an approval receives prop `interrupt`. + +## Type a component in its own file + +Type the `$props()` of a tool file with `ToolProps`. Share the same `chatOptions` module that you pass to `createChatUI`. + +```svelte + + +{part.input?.city} +``` + +Part components use `PartProps`. Then `part` is already a text part. + +Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. + +Mapped components do not receive `chat` as a prop. Call `ui.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. + +## Read chat from `ui.useChat()` + +Import the same `ui` descriptor in a child file. Call `ui.useChat()` only under `UIChat` or `UIProvider`. + +```svelte + + +

{chat.messages.length} messages

+``` + +`createChat(chatOptions)` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`. A call outside that tree throws. + +## Interrupts + +Tool approvals sit in the tool when you read the `interrupt` prop. Put a component on `interrupts.tools` to send that approval to the list instead. Generic interrupts always sit in the list under `interrupts.generic`: `{ choosePlan, fallback }`. An unbound interrupt uses `fallback`. Branch on `interrupt.kind === 'unbound'` if the copy must differ. + +The full map is on the [React page](./react). diff --git a/docs/ui/vue.md b/docs/ui/vue.md new file mode 100644 index 0000000000..0624e31bb6 --- /dev/null +++ b/docs/ui/vue.md @@ -0,0 +1,170 @@ +--- +title: Vue Chat UI +id: typed-headless-ui-vue +order: 3 +description: "Build a typed, headless Vue chat UI with createChatUI and static primitives. Slots replace render callbacks." +keywords: + - tanstack ai + - createChatUI + - vue + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-vue-ui`. Call `createChatUI(chatOptions)` once. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives. + +`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional. + +The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. + +## Client + +```ts +import { defineComponent, h } from 'vue' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue' +import { createChatUI, UIChat } from '@tanstack/ai-vue-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +const ui = createChatUI(chatOptions) + +const components = ui.defineComponents({ + layout: defineComponent((_, { slots }) => () => + h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]), + ), + message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())), + parts: { + fallback: defineComponent({ + props: ['part'], + setup(props) { + return () => h('span', props.part.type) + }, + }), + }, + tools: { + getWeather: defineComponent({ + props: ['part'], + setup(props) { + return () => h('strong', props.part.input?.city) + }, + }), + }, +}) + +export default defineComponent({ + setup() { + const chat = useChat(chatOptions) + return () => h(UIChat, { ui, chat, components }) + }, +}) +``` + +Layout uses slots `messages`, `interrupts`, and `input`. Message uses slot `parts`. Manual lists use the default slot on `UIMessages` with `{ messages }`. + +## Type a component in its own file + +Use `ToolProps` on the component props. Share the same `chatOptions` object that you pass to `createChatUI`. + +```ts +import { defineComponent, h } from 'vue' +import { fetchServerSentEvents } from '@tanstack/ai-vue' +import { createChatUI, type ToolProps } from '@tanstack/ai-vue-ui' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +export const WeatherTool = defineComponent( + (props: ToolProps) => { + return () => h('strong', props.part.input?.city) + }, +) + +const ui = createChatUI(chatOptions) + +export const components = ui.defineComponents({ + layout: defineComponent((_, { slots }) => () => + h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]), + ), + message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())), + parts: { fallback: defineComponent(() => () => null) }, + tools: { getWeather: WeatherTool }, +}) +``` + +Part components use `PartProps`. Then `part` is already a text part. + +Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. + +Mapped components do not receive `chat` as a prop. Call `ui.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too. + +## Read chat from `ui.useChat()` + +Call `ui.useChat()` inside a child of `UIChat` or `UIProvider`. + +```ts +import { defineComponent, h } from 'vue' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue' +import { createChatUI, UIChat } from '@tanstack/ai-vue-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const ui = createChatUI(chatOptions) + +const StatusLine = defineComponent({ + setup() { + const chat = ui.useChat() + return () => { + const messages = Array.isArray(chat.messages) ? chat.messages : [] + return h('p', String(messages.length) + ' messages') + } + }, +}) + +const components = ui.defineComponents({ + layout: defineComponent((_, { slots }) => () => + h('main', [h(StatusLine), slots.messages?.()]), + ), + message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())), + parts: { fallback: defineComponent(() => () => null) }, +}) + +export default defineComponent({ + setup() { + const chat = useChat(chatOptions) + return () => h(UIChat, { ui, chat, components }) + }, +}) +``` + +`useChat(chatOptions)` from `@tanstack/ai-vue` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`. + +## Interrupts + +Tool approvals sit in the tool when you read the `interrupt` prop. Put a component on `interrupts.tools` to send that approval to the list instead. Generic interrupts always sit in the list under `interrupts.generic`: `{ choosePlan, fallback }`. An unbound interrupt uses `fallback`. Branch on `interrupt.kind === 'unbound'` if the copy must differ. + +The full map is on the [React page](./react). diff --git a/examples/ag-ui/src/App.tsx b/examples/ag-ui/src/App.tsx index 8fca0f93a2..84b0f5180e 100644 --- a/examples/ag-ui/src/App.tsx +++ b/examples/ag-ui/src/App.tsx @@ -1,12 +1,71 @@ import { useEffect, useMemo, useState } from 'react' -import { fetchServerSentEvents } from '@tanstack/ai-react' -import type { UIMessage } from '@tanstack/ai-react' -import { - Chat, - ChatInput, - ChatMessage, - ChatMessages, -} from '@tanstack/ai-react-ui' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { ConnectionAdapter } from '@tanstack/ai-react' +import { ChatMessage, createChatUI } from '@tanstack/ai-react-ui' + +const UI = createChatUI({}) + +function AgUiChat({ + connection, + placeholder, + emptyLabel, +}: { + connection: ConnectionAdapter + placeholder: string + emptyLabel: string +}) { + const chat = useChat({ connection }) + const [draft, setDraft] = useState('') + const components = UI.defineComponents({ + layout: ({ renderMessages, renderInput }) => { + const current = UI.useChat() + return ( +
+ {current.error ? ( +

{current.error.message}

+ ) : current.messages.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
+ {renderMessages()} +
+ )} +
{renderInput()}
+
+ ) + }, + message: ({ message }) => , + input: () => ( +
{ + event.preventDefault() + const text = draft.trim() + if (!text) return + setDraft('') + void chat.sendMessage(text) + }} + > + setDraft(event.target.value)} + /> + +
+ ), + parts: { fallback: () => null }, + }) + return +} type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python' type Provider = 'openai' | 'anthropic' @@ -280,28 +339,12 @@ export function App() { Loading backend availability… ) : active.available && connection ? ( - - - Chat with {active.label} over AG-UI SSE using{' '} - {activeProvider.label}. - - } - > - {(message: UIMessage) => } - -
- -
-
+ placeholder={`Message via ${active.label} + ${activeProvider.label}…`} + emptyLabel={`Chat with ${active.label} over AG-UI SSE using ${activeProvider.label}.`} + /> ) : ( )} diff --git a/examples/ts-react-ui-chatbot/.gitignore b/examples/ts-react-ui-chatbot/.gitignore new file mode 100644 index 0000000000..4ece634c04 --- /dev/null +++ b/examples/ts-react-ui-chatbot/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.env +.nitro +.tanstack +.output +.vinxi +.data diff --git a/examples/ts-react-ui-chatbot/README.md b/examples/ts-react-ui-chatbot/README.md new file mode 100644 index 0000000000..b7d7e829c7 --- /dev/null +++ b/examples/ts-react-ui-chatbot/README.md @@ -0,0 +1,12 @@ +# Typed headless chat + shadcn AI components + +One-route TanStack Start app. It uses `createChatUI()` from `@tanstack/ai-react-ui` and the chatbot pieces from [shadcn.io/ai/chatbot](https://www.shadcn.io/ai/chatbot): Conversation, Message, Prompt Input, Model Selector, Reasoning, Sources, plus Tool for approvals. + +```bash +pnpm install +cp env.example .env +# set OPENAI_API_KEY +pnpm --filter ts-react-ui-chatbot dev +``` + +Open http://localhost:3000. diff --git a/examples/ts-react-ui-chatbot/env.example b/examples/ts-react-ui-chatbot/env.example new file mode 100644 index 0000000000..e570b8b559 --- /dev/null +++ b/examples/ts-react-ui-chatbot/env.example @@ -0,0 +1 @@ +OPENAI_API_KEY= diff --git a/examples/ts-react-ui-chatbot/package.json b/examples/ts-react-ui-chatbot/package.json new file mode 100644 index 0000000000..9508019edc --- /dev/null +++ b/examples/ts-react-ui-chatbot/package.json @@ -0,0 +1,48 @@ +{ + "name": "ts-react-ui-chatbot", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "build": "vite build", + "serve": "vite preview", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/react-devtools": "^0.9.10", + "@tanstack/react-router": "^1.158.4", + "@tanstack/react-router-devtools": "^1.158.4", + "@tanstack/react-start": "^1.159.0", + "@tanstack/router-plugin": "^1.158.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.561.0", + "nitro": "3.0.260610-beta", + "radix-ui": "^1.4.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "use-stick-to-bottom": "^1.1.1", + "zod": "^4.2.0" + }, + "devDependencies": { + "@tanstack/devtools-vite": "^0.5.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "5.9.3", + "vite": "^8.2.1" + } +} diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf b/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf new file mode 100644 index 0000000000..ea2465b48a Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf differ diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000..399a6e0c3f Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf differ diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2 b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2 new file mode 100644 index 0000000000..b0d0e2e5c6 Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2 differ diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt b/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt new file mode 100644 index 0000000000..4dfd4fe066 --- /dev/null +++ b/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg new file mode 100644 index 0000000000..ce610b729e --- /dev/null +++ b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg @@ -0,0 +1 @@ + diff --git a/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg new file mode 100644 index 0000000000..50ad4162f5 --- /dev/null +++ b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/examples/ts-react-ui-chatbot/public/favicon.ico b/examples/ts-react-ui-chatbot/public/favicon.ico new file mode 100644 index 0000000000..a11777cc47 Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/favicon.ico differ diff --git a/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png b/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png new file mode 100644 index 0000000000..9db3e67bad Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png differ diff --git a/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg b/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg new file mode 100644 index 0000000000..b6ec5086c2 --- /dev/null +++ b/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/ts-react-ui-chatbot/src/chat/byok.ts b/examples/ts-react-ui-chatbot/src/chat/byok.ts new file mode 100644 index 0000000000..c902cf3d37 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/byok.ts @@ -0,0 +1,9 @@ +import { defineByok, defaultByokStorage } from '@tanstack/ai-client/byok' + +export const byok = defineByok({ + storage: defaultByokStorage(), +}) + +// Env OPENAI_API_KEY on the server can fill the request. Do not block send +// when the browser has no saved key. +byok.setServerCoverage(true) diff --git a/examples/ts-react-ui-chatbot/src/chat/interrupts.ts b/examples/ts-react-ui-chatbot/src/chat/interrupts.ts new file mode 100644 index 0000000000..67dbed365c --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/interrupts.ts @@ -0,0 +1,17 @@ +import { defineInterrupt } from '@tanstack/ai' +import { z } from 'zod' + +export const BUDGET_OPTIONS = ['thrifty', 'comfort', 'splash'] as const + +export const chooseBudget = defineInterrupt({ + id: 'chooseBudget', + payloadSchema: z.object({ + city: z.string(), + options: z.array(z.enum(BUDGET_OPTIONS)), + }), + responseSchema: z.object({ + budget: z.enum(BUDGET_OPTIONS), + }), +}) + +export const chatInterrupts = [chooseBudget] diff --git a/examples/ts-react-ui-chatbot/src/chat/model.ts b/examples/ts-react-ui-chatbot/src/chat/model.ts new file mode 100644 index 0000000000..9c2910f112 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/model.ts @@ -0,0 +1,7 @@ +import type { ChatModelId } from '@/components/ai/model-selector' + +export let selectedModel: ChatModelId = 'gpt-5.5' + +export function setSelectedModel(next: ChatModelId) { + selectedModel = next +} diff --git a/examples/ts-react-ui-chatbot/src/chat/options.ts b/examples/ts-react-ui-chatbot/src/chat/options.ts new file mode 100644 index 0000000000..73870c0f50 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/options.ts @@ -0,0 +1,17 @@ +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createChatUI } from '@tanstack/ai-react-ui' +import { byok } from './byok' +import { chatInterrupts } from './interrupts' +import { itinerarySchema } from './schema' +import { clientTools } from './tools' + +export const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: clientTools, + interrupts: chatInterrupts, + outputSchema: itinerarySchema, + byok, + byokProvider: () => 'openai' as const, +} + +export const UI = createChatUI(chatOptions) diff --git a/examples/ts-react-ui-chatbot/src/chat/schema.ts b/examples/ts-react-ui-chatbot/src/chat/schema.ts new file mode 100644 index 0000000000..76bcb1fa77 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const itinerarySchema = z.object({ + title: z.string(), + summary: z.string(), + days: z.array( + z.object({ + label: z.string(), + plan: z.string(), + }), + ), +}) diff --git a/examples/ts-react-ui-chatbot/src/chat/tools.ts b/examples/ts-react-ui-chatbot/src/chat/tools.ts new file mode 100644 index 0000000000..278a2e47b6 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/chat/tools.ts @@ -0,0 +1,48 @@ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const lookupPlace = toolDefinition({ + name: 'lookupPlace', + description: 'Look up a place, weather, and citation links.', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ + city: z.string(), + blurb: z.string(), + sources: z.array(z.object({ title: z.string(), url: z.string() })), + }), +}) + +export const bookStay = toolDefinition({ + name: 'bookStay', + description: 'Hold a hotel or guesthouse. Needs approval before it runs.', + needsApproval: true, + inputSchema: z.object({ + city: z.string(), + nights: z.number(), + }), + outputSchema: z.object({ + ok: z.boolean(), + confirmation: z.string(), + }), +}) + +export const confirmPayment = toolDefinition({ + name: 'confirmPayment', + description: + 'Demo hold for a trip amount. Call this when the user asks to pay or hold a charge. Not a real card charge.', + needsApproval: true, + inputSchema: z.object({ + city: z.string(), + amount: z.number(), + }), + outputSchema: z.object({ + ok: z.boolean(), + receipt: z.string(), + }), +}) + +export const clientTools = [ + lookupPlace.client(), + bookStay.client(), + confirmPayment.client(), +] diff --git a/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx b/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx new file mode 100644 index 0000000000..e016d9b095 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx @@ -0,0 +1,103 @@ +import type { ComponentProps } from 'react' +import { useCallback } from 'react' +import { ArrowDownIcon } from 'lucide-react' +import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' +import { Button } from '@/components/ui/button' +import cn from '@/utils/cn' + +export type ConversationProps = ComponentProps + +export function Conversation({ className, ...props }: ConversationProps) { + return ( + + ) +} + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +> + +export function ConversationContent({ + className, + ...props +}: ConversationContentProps) { + return ( + + ) +} + +export type ConversationEmptyStateProps = ComponentProps<'div'> & { + title?: string + description?: string + icon?: React.ReactNode +} + +export function ConversationEmptyState({ + className, + title = 'No messages yet', + description = 'Start a conversation to see messages here', + icon, + children, + ...props +}: ConversationEmptyStateProps) { + return ( +
+ {children ?? ( + <> + {icon ?
{icon}
: null} +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ + )} +
+ ) +} + +export type ConversationScrollButtonProps = ComponentProps + +export function ConversationScrollButton({ + className, + ...props +}: ConversationScrollButtonProps) { + const { isAtBottom, scrollToBottom } = useStickToBottomContext() + const handleScrollToBottom = useCallback(() => { + scrollToBottom() + }, [scrollToBottom]) + + if (isAtBottom) return null + + return ( + + ) +} diff --git a/examples/ts-react-ui-chatbot/src/components/ai/message.tsx b/examples/ts-react-ui-chatbot/src/components/ai/message.tsx new file mode 100644 index 0000000000..38bc30f689 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/components/ai/message.tsx @@ -0,0 +1,89 @@ +import type { HTMLAttributes } from 'react' +import { memo } from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import cn from '@/utils/cn' + +export type MessageProps = HTMLAttributes & { + from: 'user' | 'assistant' | 'system' +} + +export function Message({ className, from, ...props }: MessageProps) { + return ( +
+ ) +} + +export type MessageContentProps = HTMLAttributes + +export function MessageContent({ + children, + className, + ...props +}: MessageContentProps) { + return ( +
+ {children} +
+ ) +} + +export type MessageAvatarProps = { + name?: string + src?: string + className?: string +} + +export function MessageAvatar({ name, src, className }: MessageAvatarProps) { + const initials = (name ?? '?').slice(0, 1).toUpperCase() + return ( +
+ {src ? ( + {name} + ) : ( + initials + )} +
+ ) +} + +export const MessageResponse = memo(function MessageResponse({ + className, + children, +}: { + className?: string + children?: React.ReactNode +}) { + const text = typeof children === 'string' ? children : String(children ?? '') + return ( +
*:first-child]:mt-0 [&>*:last-child]:mb-0', + className, + )} + > + {text} +
+ ) +}) diff --git a/examples/ts-react-ui-chatbot/src/components/ai/model-selector.tsx b/examples/ts-react-ui-chatbot/src/components/ai/model-selector.tsx new file mode 100644 index 0000000000..1b751fddf7 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/components/ai/model-selector.tsx @@ -0,0 +1,42 @@ +import { + PromptInputModelSelect, + PromptInputModelSelectContent, + PromptInputModelSelectItem, + PromptInputModelSelectTrigger, + PromptInputModelSelectValue, +} from '@/components/ai/prompt-input' + +export const CHAT_MODELS = [ + { id: 'gpt-5.5', label: 'GPT-5.5' }, + { id: 'gpt-5.2', label: 'GPT-5.2' }, +] as const + +export type ChatModelId = (typeof CHAT_MODELS)[number]['id'] + +export function ModelSelector({ + value, + onChange, +}: { + value: ChatModelId + onChange: (value: ChatModelId) => void +}) { + return ( + { + if (next === 'gpt-5.5' || next === 'gpt-5.2') onChange(next) + }} + value={value} + > + + + + + {CHAT_MODELS.map((model) => ( + + {model.label} + + ))} + + + ) +} diff --git a/examples/ts-react-ui-chatbot/src/components/ai/prompt-input.tsx b/examples/ts-react-ui-chatbot/src/components/ai/prompt-input.tsx new file mode 100644 index 0000000000..dc3fde7661 --- /dev/null +++ b/examples/ts-react-ui-chatbot/src/components/ai/prompt-input.tsx @@ -0,0 +1,124 @@ +import type { ComponentProps, FormEvent } from 'react' +import { Loader2Icon, SendIcon, SquareIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import cn from '@/utils/cn' + +export function PromptInput({ + className, + onSubmit, + ...props +}: ComponentProps<'form'>) { + return ( +
+ ) +} + +export function PromptInputTextarea({ + className, + ...props +}: ComponentProps<'textarea'>) { + return ( +