diff --git a/.changeset/typed-headless-chat-ui.md b/.changeset/typed-headless-chat-ui.md new file mode 100644 index 0000000000..9a201bca4e --- /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 `createUI()` adapters. Chat options control the types of message parts, tools, structured output, and interrupts. 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 16456962b5..9edd42f279 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..241a8a251d 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 createUI](../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..2cc7cccb7a 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 createUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 60488ce058..639f39ae15 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 createUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index 6606cf4444..8e82e46e8b 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 createUI](../migration/create-ui). + ## Installation ```bash diff --git a/docs/config.json b/docs/config.json index dfe4cd2779..74ba0b9179 100644 --- a/docs/config.json +++ b/docs/config.json @@ -189,6 +189,42 @@ } ] }, + { + "label": "UI", + "tab": "guides", + "children": [ + { + "label": "React", + "to": "ui/react", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + }, + { + "label": "Solid", + "to": "ui/solid", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + }, + { + "label": "Vue", + "to": "ui/vue", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + }, + { + "label": "Svelte", + "to": "ui/svelte", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + }, + { + "label": "Custom Adapters", + "to": "ui/custom-adapters", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + } + ] + }, { "label": "Interrupts", "tab": "guides", @@ -821,6 +857,12 @@ "addedAt": "2026-04-15", "updatedAt": "2026-07-22" }, + { + "label": "createUI", + "to": "migration/create-ui", + "addedAt": "2026-08-26", + "updatedAt": "2026-08-26" + }, { "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai", @@ -853,19 +895,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", @@ -877,13 +919,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..9d311c293b --- /dev/null +++ b/docs/migration/create-ui.md @@ -0,0 +1,117 @@ +--- +title: Migrate to createUI +id: migrate-create-ui +order: 5 +description: "Move chat-state ownership out of the old Chat component and onto createUI with a typed component map." +keywords: + - tanstack ai + - createUI + - migration + - deprecation +--- + +The old `Chat` component owned chat state and lost configured types. `createUI` 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. `createUI()` 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.1.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 { createUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInput }) => ( +
+ {renderMessages()} + {renderInput()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + input: ({ chat }) => ( +
{ + 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 `createUI(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`. +- `{ component, placement: 'inline' }` puts a tool approval in the tool slot. A direct tool interrupt component uses the list. +- Generic interrupts live under `interrupts.generic`: a registered id such as `choosePlan`, plus `fallback`. Unbound interrupts use `fallback`. +- 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..b0ae0c3201 --- /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 + - createUI + - 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..0a1732bde2 --- /dev/null +++ b/docs/ui/react.md @@ -0,0 +1,549 @@ +--- +title: React Chat UI +id: typed-headless-ui-react +order: 1 +description: "Build a typed, headless React chat UI with createUI. Your chat options control the types of tools, parts, and interrupts." +keywords: + - tanstack ai + - createUI + - react + - headless ui + - useChat + - ToolProps +--- + +Install `@tanstack/ai-react-ui`, then call `createUI(chatOptions)` once at module scope. Your app owns `useChat`. The UI only renders. + +You supply every visible component. There is no default markup, style, or copy. + +## 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.2'), + messages: Array.isArray(messages) ? messages : [], + }) + return toServerSentEventsResponse(stream) +} +``` + +## Client + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createUI } 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 = createUI(chatOptions) + +const components = UI.defineComponents({ + layout: function Layout({ + chat, + renderMessages, + renderInterrupts, + renderInput, + }) { + 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({ chat }) { + 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.type === 'text' ?

{part.content}

: null), + structuredOutput: ({ part }) => + part.type === 'structured-output' ? ( +
{part.raw}
+ ) : null, + toolResult: ({ part }) => + part.type === 'tool-result' ? {String(part.content)} : null, + 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, renderInterrupt }) => ( +
+ {part.input?.item} + {renderInterrupt()} +
+ ), + }, + interrupts: { + tools: { + purchaseItem: { + component: ({ interrupt }) => + interrupt.kind === 'tool-approval' ? ( + interrupt.status === 'pending' ? ( + + ) : ( + {interrupt.status} + ) + ) : null, + placement: 'inline', + }, + }, + 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. + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createUI, 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)} +

+ ) +} + +const UI = createUI(chatOptions) + +export const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + tools: { getWeather: WeatherTool }, +}) +``` + +1. Put `chatOptions` in a shared module. +2. Import `ToolProps` from `@tanstack/ai-react-ui`. +3. Type the component with `ToolProps`. +4. Pass that component into `tools.getWeather`. + +For a registered generic interrupt, use `RegisteredInterruptProps`. Then `interrupt.payload` and `interrupt.resolveInterrupt` match the definition. + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { createUI, type RegisteredInterruptProps } 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, +}: RegisteredInterruptProps) { + return ( + + ) +} + +const UI = createUI(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` +- `InterruptProps` for tool approvals and `generic.fallback` + +## Read chat from `UI.useChat()` + +Every mapped component already gets `chat` as a prop. Nested children that you write yourself can call `UI.useChat()` instead of threading that prop. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createUI(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 + +Set `placement: 'inline'`. Call `renderInterrupt()` inside the tool component. The approval appears in the tool slot. It does not appear in the interrupt list. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createUI } 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 = createUI(chatOptions) + +const components = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( +
+ {renderMessages()} + {renderInterrupts()} +
+ ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: () => null }, + tools: { + purchaseItem: ({ part, renderInterrupt }) => ( +
+ {part.input?.item} + {renderInterrupt()} +
+ ), + }, + interrupts: { + tools: { + purchaseItem: { + component: ({ interrupt }) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + placement: 'inline', + }, + }, + }, +}) + +export function InlineApprovalChat() { + const chat = useChat(chatOptions) + return +} +``` + +### List, in `renderInterrupts()` + +Pass the approval component directly. Do not set `placement: 'inline'`. The tool can skip `renderInterrupt()`. The approval appears in the interrupt list. + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { createUI } 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 = createUI(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 }) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + }, + }, +}) + +export function ListApprovalChat() { + const chat = useChat(chatOptions) + return +} +``` + +`placement: 'list'` is the same as a direct component. + +## 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 { createUI } 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 = createUI(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 { createUI } from '@tanstack/ai-react-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createUI(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..da5ab4ace1 --- /dev/null +++ b/docs/ui/solid.md @@ -0,0 +1,148 @@ +--- +title: Solid Chat UI +id: typed-headless-ui-solid +order: 2 +description: "Build a typed, headless Solid chat UI with createUI. Accessors stay tracked. Your app owns useChat." +keywords: + - tanstack ai + - createUI + - solid + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-solid-ui`, then call `createUI(chatOptions)` once at module scope. Do not destructure reactive props. + +The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter. + +## Client + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' +import { createUI } 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 = createUI(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}, + }, + interrupts: { generic: { fallback: () => null } }, +}) + +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 { createUI, 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 = createUI(chatOptions) + +export const components = UI.defineComponents({ + layout: (props) => props.renderMessages(), + message: (props) =>
{props.renderParts()}
, + parts: { fallback: () => null }, + tools: { getWeather: WeatherTool }, +}) +``` + +Registered generic interrupts use `RegisteredInterruptProps`. + +## Read chat from `UI.useChat()` + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' +import { createUI } from '@tanstack/ai-solid-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const UI = createUI(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 can sit in the tool (`placement: 'inline'` plus `props.renderInterrupt()`) or in the list (a direct component). 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..2041b07586 --- /dev/null +++ b/docs/ui/svelte.md @@ -0,0 +1,96 @@ +--- +title: Svelte Chat UI +id: typed-headless-ui-svelte +order: 4 +description: "Build a typed, headless Svelte 5 chat UI with createUI, snippets, and static components." +keywords: + - tanstack ai + - createUI + - svelte + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-svelte-ui`. Call `createUI(chatOptions)` once. Pass `{ui}`, `{chat}`, and `{components}` into `UIChat`. + +The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter. + +## Client + +```svelte + + + +``` + +`Layout.svelte` receives snippets `messages`, `interrupts`, and `input`. `Message.svelte` receives snippet `parts`. A tool with an inline approval receives snippet `renderInterrupt`. + +## 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 `createUI`. + +```svelte + + +{part.input?.city} +``` + +Registered generic interrupts use `RegisteredInterruptProps`. + +## 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 can sit in the tool (`placement: 'inline'` plus the `renderInterrupt` snippet) or in the list (a direct component). 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..de35f5845f --- /dev/null +++ b/docs/ui/vue.md @@ -0,0 +1,161 @@ +--- +title: Vue Chat UI +id: typed-headless-ui-vue +order: 3 +description: "Build a typed, headless Vue chat UI with createUI and static primitives. Slots replace render callbacks." +keywords: + - tanstack ai + - createUI + - vue + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-vue-ui`. Call `createUI(chatOptions)` once. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives. + +The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter. + +## Client + +```ts +import { defineComponent, h } from 'vue' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue' +import { createUI, 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 = createUI(chatOptions) + +const components = ui.defineComponents({ + layout: defineComponent((_, { slots }) => () => slots.messages?.()), + 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) + }, + }), + }, + interrupts: { generic: { fallback: defineComponent(() => () => null) } }, +}) + +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 `createUI`. + +```ts +import { defineComponent, h } from 'vue' +import { fetchServerSentEvents } from '@tanstack/ai-vue' +import { createUI, 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 = createUI(chatOptions) + +export const components = ui.defineComponents({ + layout: defineComponent((_, { slots }) => () => slots.messages?.()), + message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())), + parts: { fallback: defineComponent(() => () => null) }, + tools: { getWeather: WeatherTool }, +}) +``` + +Registered generic interrupts use `RegisteredInterruptProps`. + +## 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 { createUI, UIChat } from '@tanstack/ai-vue-ui' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), +} + +const ui = createUI(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 can sit in the tool (`placement: 'inline'` plus the `renderInterrupt` slot) or in the list (a direct component). 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..74fd4ded5d 100644 --- a/examples/ag-ui/src/App.tsx +++ b/examples/ag-ui/src/App.tsx @@ -1,12 +1,68 @@ 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, createUI } from '@tanstack/ai-react-ui' + +const UI = createUI({}) + +function AgUiChat({ + connection, + placeholder, + emptyLabel, +}: { + connection: ConnectionAdapter + placeholder: string + emptyLabel: string +}) { + const chat = useChat({ connection }) + const [draft, setDraft] = useState('') + const components = UI.defineComponents({ + layout: ({ chat: current, renderMessages, renderInput }) => ( +
+ {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 +336,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-solid-chat/src/routeTree.gen.ts b/examples/ts-solid-chat/src/routeTree.gen.ts index f474ce588d..09f7e9514c 100644 --- a/examples/ts-solid-chat/src/routeTree.gen.ts +++ b/examples/ts-solid-chat/src/routeTree.gen.ts @@ -9,11 +9,17 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SolidUiRouteImport } from './routes/solid-ui' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' +const SolidUiRoute = SolidUiRouteImport.update({ + id: '/solid-ui', + path: '/solid-ui', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -37,12 +43,14 @@ const ExampleGuitarsGuitarIdRoute = ExampleGuitarsGuitarIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/solid-ui': typeof SolidUiRoute '/api/chat': typeof ApiChatRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars': typeof ExampleGuitarsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/solid-ui': typeof SolidUiRoute '/api/chat': typeof ApiChatRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars': typeof ExampleGuitarsIndexRoute @@ -50,6 +58,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/solid-ui': typeof SolidUiRoute '/api/chat': typeof ApiChatRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute @@ -58,14 +67,21 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/solid-ui' | '/api/chat' | '/example/guitars/$guitarId' | '/example/guitars' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/chat' | '/example/guitars/$guitarId' | '/example/guitars' + to: + | '/' + | '/solid-ui' + | '/api/chat' + | '/example/guitars/$guitarId' + | '/example/guitars' id: | '__root__' | '/' + | '/solid-ui' | '/api/chat' | '/example/guitars/$guitarId' | '/example/guitars/' @@ -73,6 +89,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + SolidUiRoute: typeof SolidUiRoute ApiChatRoute: typeof ApiChatRoute ExampleGuitarsGuitarIdRoute: typeof ExampleGuitarsGuitarIdRoute ExampleGuitarsIndexRoute: typeof ExampleGuitarsIndexRoute @@ -80,6 +97,13 @@ export interface RootRouteChildren { declare module '@tanstack/solid-router' { interface FileRoutesByPath { + '/solid-ui': { + id: '/solid-ui' + path: '/solid-ui' + fullPath: '/solid-ui' + preLoaderRoute: typeof SolidUiRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -113,6 +137,7 @@ declare module '@tanstack/solid-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SolidUiRoute: SolidUiRoute, ApiChatRoute: ApiChatRoute, ExampleGuitarsGuitarIdRoute: ExampleGuitarsGuitarIdRoute, ExampleGuitarsIndexRoute: ExampleGuitarsIndexRoute, diff --git a/examples/ts-solid-chat/src/routes/solid-ui.tsx b/examples/ts-solid-chat/src/routes/solid-ui.tsx new file mode 100644 index 0000000000..22e60f6e98 --- /dev/null +++ b/examples/ts-solid-chat/src/routes/solid-ui.tsx @@ -0,0 +1,61 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' +import { createUI } from '@tanstack/ai-solid-ui' +import { createSignal } from 'solid-js' +import { clientTools } from '@/lib/guitar-tools' + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: clientTools, +} + +const UI = createUI(chatOptions) + +function SolidUIPage() { + const chat = useChat(chatOptions) + const [draft, setDraft] = createSignal('') + const components = UI.defineComponents({ + layout: (props) => ( +
+
+ {props.renderMessages()} +
+ {props.renderInput()} +
+ ), + message: (props) => ( +
+ {props.renderParts()} +
+ ), + input: () => ( +
{ + event.preventDefault() + const text = draft().trim() + if (!text) return + setDraft('') + void chat.sendMessage(text) + }} + > + setDraft(event.currentTarget.value)} + /> +
+ ), + parts: { + fallback: (props) => + props.part.type === 'text' ?

{props.part.content}

: null, + }, + }) + + return +} + +export const Route = createFileRoute('/solid-ui')({ + component: SolidUIPage, +}) diff --git a/examples/ts-svelte-chat/package.json b/examples/ts-svelte-chat/package.json index 80a3e08237..5ce76cb7c7 100644 --- a/examples/ts-svelte-chat/package.json +++ b/examples/ts-svelte-chat/package.json @@ -20,6 +20,7 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-svelte": "workspace:*", + "@tanstack/ai-svelte-ui": "workspace:*", "highlight.js": "^11.11.1", "lucide-svelte": "^0.468.0", "marked": "^15.0.6", diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte new file mode 100644 index 0000000000..7a354b566d --- /dev/null +++ b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte @@ -0,0 +1,71 @@ + + + + TanStack AI - Svelte createUI + + + diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte new file mode 100644 index 0000000000..636744520d --- /dev/null +++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte @@ -0,0 +1,7 @@ + + +{#if part.type === 'text'} +

{part.content}

+{/if} diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte new file mode 100644 index 0000000000..1dab0ceebb --- /dev/null +++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte @@ -0,0 +1,35 @@ + + +
+
+ {@render messages?.()} +
+
{ + event.preventDefault() + const text = draft.trim() + if (!text) return + draft = '' + void chat.sendMessage(text) + }} + > + +
+
diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte new file mode 100644 index 0000000000..d3dd175e52 --- /dev/null +++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte @@ -0,0 +1,15 @@ + + +
+ {@render parts?.()} +
diff --git a/examples/ts-vue-chat/src/views/VueUIView.vue b/examples/ts-vue-chat/src/views/VueUIView.vue index c45bb4b937..763de0b8ce 100644 --- a/examples/ts-vue-chat/src/views/VueUIView.vue +++ b/examples/ts-vue-chat/src/views/VueUIView.vue @@ -1,8 +1,8 @@ diff --git a/kiira.config.ts b/kiira.config.ts index b85ad1212a..ffb8cef276 100644 --- a/kiira.config.ts +++ b/kiira.config.ts @@ -74,7 +74,7 @@ export default defineConfig({ }, // Solid snippets compile JSX through solid-js. { - include: ['docs/api/ai-solid.md'], + include: ['docs/api/ai-solid.md', 'docs/ui/solid.md'], jsx: 'preserve', jsxImportSource: 'solid-js', }, diff --git a/packages/ai-client/package.json b/packages/ai-client/package.json index d8112524e9..010c941290 100644 --- a/packages/ai-client/package.json +++ b/packages/ai-client/package.json @@ -46,6 +46,10 @@ "./byok": { "types": "./dist/esm/byok.d.ts", "import": "./dist/esm/byok.js" + }, + "./ui": { + "types": "./dist/esm/ui.d.ts", + "import": "./dist/esm/ui.js" } }, "files": [ diff --git a/packages/ai-client/src/ui.ts b/packages/ai-client/src/ui.ts new file mode 100644 index 0000000000..c57654aeaf --- /dev/null +++ b/packages/ai-client/src/ui.ts @@ -0,0 +1,30 @@ +export { partTypeToKey, selectChatUI } from './ui/selectors' +export { + automaticPartsForMessage, + collectInlineToolNames, + getMappedComponent, + isInlineInterruptConfig, + resolveInterruptComponent, + selectMessageUI, +} from './ui/selectors' +export type { + ChatUIData, + ChatUIInterrupt, + ChatUIInterruptsOf, + ChatUIMessages, + ChatUIPartKey, + ChatUIRegisteredInterruptId, + ChatUISchemaOf, + ChatUISelectInput, + ChatUISelectedGenericPart, + ChatUISelectedMessage, + ChatUISelectedPart, + ChatUISelectedResultPart, + ChatUISelectedToolPart, + ChatUISelection, + ChatUIStructuredPart, + ChatUIToolName, + ChatUIToolPart, + ChatUIToolsOf, + RegisteredUIInterrupt, +} from './ui/types' diff --git a/packages/ai-client/src/ui/selectors.ts b/packages/ai-client/src/ui/selectors.ts new file mode 100644 index 0000000000..5527548fce --- /dev/null +++ b/packages/ai-client/src/ui/selectors.ts @@ -0,0 +1,200 @@ +import type { + MessagePart, + ToolApprovalInterrupt, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '../types' +import type { + ChatUIInterrupt, + ChatUIPartKey, + ChatUISelectInput, + ChatUISelectedMessage, + ChatUISelectedPart, + ChatUISelection, +} from './types' + +const PART_KEY_BY_TYPE: Record = { + text: 'text', + image: 'image', + audio: 'audio', + video: 'video', + document: 'document', + thinking: 'thinking', + 'tool-call': 'toolCall', + 'tool-result': 'toolResult', + 'structured-output': 'structuredOutput', + 'ui-resource': 'uiResource', +} + +export function partTypeToKey(type: string): ChatUIPartKey | string { + const mapped = PART_KEY_BY_TYPE[type] + if (mapped) return mapped + return type.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()) +} + +export function isInlineInterruptConfig(entry: unknown): boolean { + return ( + typeof entry === 'object' && + entry !== null && + 'placement' in entry && + (entry as { placement?: unknown }).placement === 'inline' + ) +} + +export function getMappedComponent(entry: unknown): unknown { + if ( + typeof entry === 'object' && + entry !== null && + 'component' in entry && + 'placement' in entry + ) { + return (entry as { component: unknown }).component + } + return entry +} + +export function collectInlineToolNames( + toolInterrupts?: Record, +): Array { + if (!toolInterrupts) return [] + return Object.keys(toolInterrupts).filter((name) => + isInlineInterruptConfig(toolInterrupts[name]), + ) +} + +export function resolveInterruptComponent( + interrupt: ChatUIInterrupt, + interruptsMap: + | { + tools?: Record + generic?: Record + } + | undefined, +): unknown { + if (!interruptsMap) return undefined + if (interrupt.kind === 'tool-approval') { + return getMappedComponent(interruptsMap.tools?.[interrupt.toolName]) + } + const generic = interruptsMap.generic + if (!generic) return undefined + const definitionId = + 'definitionId' in interrupt && typeof interrupt.definitionId === 'string' + ? interrupt.definitionId + : undefined + if (definitionId && definitionId !== 'fallback') { + const registered = generic[definitionId] + if (registered) return getMappedComponent(registered) + } + return generic.fallback +} + +function isToolCallPart(part: MessagePart): part is ToolCallPart { + return part.type === 'tool-call' +} + +function isToolResultPart(part: MessagePart): part is ToolResultPart { + return part.type === 'tool-result' +} + +function isToolApproval( + interrupt: ChatUIInterrupt, +): interrupt is ToolApprovalInterrupt { + return interrupt.kind === 'tool-approval' +} + +export function selectChatUI(input: ChatUISelectInput): ChatUISelection { + const messages = input.messages + const interrupts = input.interrupts ?? [] + const inlineToolNames = new Set(input.inlineToolNames ?? []) + + const resultsByCallId = new Map() + const callIds = new Set() + for (const message of messages) { + for (const part of message.parts) { + if (isToolCallPart(part)) callIds.add(part.id) + if (isToolResultPart(part)) resultsByCallId.set(part.toolCallId, part) + } + } + + const approvalByCallId = new Map() + for (const interrupt of interrupts) { + if (isToolApproval(interrupt)) { + approvalByCallId.set(interrupt.toolCallId, interrupt) + } + } + + const selectedMessages: Array = messages.map( + (message) => ({ + message, + parts: message.parts.map((part) => + toSelectedPart(part, resultsByCallId, approvalByCallId, callIds), + ), + }), + ) + + return { + messages: selectedMessages, + interrupts: interrupts.filter((interrupt) => { + if (!isToolApproval(interrupt)) return true + if (!inlineToolNames.has(interrupt.toolName)) return true + return !callIds.has(interrupt.toolCallId) + }), + } +} + +function toSelectedPart( + part: MessagePart, + resultsByCallId: Map, + approvalByCallId: Map, + callIds: Set, +): ChatUISelectedPart { + if (isToolCallPart(part)) { + const result = resultsByCallId.get(part.id) + const interrupt = approvalByCallId.get(part.id) + return { + key: 'toolCall', + part, + ...(result ? { result } : {}), + ...(interrupt ? { interrupt } : {}), + input: part.input, + output: part.output, + } + } + + if (isToolResultPart(part)) { + return { + key: 'toolResult', + part, + matched: callIds.has(part.toolCallId), + } + } + + return { + key: partTypeToKey(part.type) as Exclude< + ChatUIPartKey, + 'toolCall' | 'toolResult' + >, + part, + } +} + +export function automaticPartsForMessage( + selected: ChatUISelectedMessage, +): Array { + return selected.parts.filter( + (part) => !(part.key === 'toolResult' && part.matched), + ) +} + +export function selectMessageUI( + message: UIMessage, + input: Omit, +): ChatUISelectedMessage { + return ( + selectChatUI({ ...input, messages: [message] }).messages[0] ?? { + message, + parts: [], + } + ) +} diff --git a/packages/ai-client/src/ui/types.ts b/packages/ai-client/src/ui/types.ts new file mode 100644 index 0000000000..026e71f564 --- /dev/null +++ b/packages/ai-client/src/ui/types.ts @@ -0,0 +1,156 @@ +import type { + AnyClientTool, + InferSchemaType, + InferToolInput, + InferToolOutput, + InterruptDefinition, + SchemaInput, +} from '@tanstack/ai/client' +import type { + ChatInterrupt, + MessagePart, + RegisteredGenericInterrupt, + StructuredOutputPart, + ToolApprovalInterrupt, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '../types' + +export type ChatUIPartKey = + | 'text' + | 'image' + | 'audio' + | 'video' + | 'document' + | 'thinking' + | 'toolCall' + | 'toolResult' + | 'structuredOutput' + | 'uiResource' + +export type ChatUIToolsOf = TOptions extends { + tools: infer TTools +} + ? TTools extends ReadonlyArray + ? TTools + : ReadonlyArray + : ReadonlyArray + +export type ChatUIInterruptsOf = TOptions extends { + interrupts: infer TInterrupts +} + ? TInterrupts extends ReadonlyArray> + ? TInterrupts + : readonly [] + : readonly [] + +export type ChatUISchemaOf = TOptions extends { + outputSchema: infer TSchema +} + ? TSchema extends SchemaInput + ? TSchema + : undefined + : undefined + +export type ChatUIData = TOptions extends { + outputSchema: infer TSchema +} + ? TSchema extends SchemaInput + ? InferSchemaType + : unknown + : unknown + +export type ChatUIToolName = + ChatUIToolsOf[number] extends infer TTool + ? TTool extends AnyClientTool + ? TTool['name'] + : string + : string + +export type ChatUIRegisteredInterruptId = + ChatUIInterruptsOf[number] extends infer TDefinition + ? TDefinition extends InterruptDefinition + ? TId + : string + : string + +type ToolByName = Extract< + ChatUIToolsOf extends ReadonlyArray ? TTool : never, + { name: TName } +> + +export type ChatUIInterrupt = ChatInterrupt | ToolApprovalInterrupt + +export type ChatUIToolPart< + TOptions, + TName extends ChatUIToolName = ChatUIToolName, +> = { + key: 'toolCall' + part: Extract>, { name: TName }> + result?: ToolResultPart + interrupt?: Extract< + ChatInterrupt, ChatUIInterruptsOf>, + { kind: 'tool-approval'; toolName: TName } + > + input?: InferToolInput> + output?: InferToolOutput> +} + +export type RegisteredUIInterrupt< + TOptions, + TId extends ChatUIRegisteredInterruptId = + ChatUIRegisteredInterruptId, +> = Extract< + RegisteredGenericInterrupt>, + { definitionId: TId } +> + +export type ChatUISelectedToolPart = { + key: 'toolCall' + part: ToolCallPart + result?: ToolResultPart + interrupt?: ChatUIInterrupt + input?: unknown + output?: unknown +} + +export type ChatUISelectedResultPart = { + key: 'toolResult' + part: ToolResultPart + matched: boolean +} + +export type ChatUISelectedGenericPart = { + key: Exclude + part: MessagePart +} + +export type ChatUISelectedPart = + | ChatUISelectedToolPart + | ChatUISelectedResultPart + | ChatUISelectedGenericPart + +export type ChatUISelectedMessage = { + message: UIMessage + parts: Array +} + +export type ChatUISelection = { + messages: Array + interrupts: Array +} + +export type ChatUISelectInput = { + messages: ReadonlyArray + interrupts?: ReadonlyArray + inlineToolNames?: ReadonlyArray +} + +export type ChatUIMessages = Array< + UIMessage, ChatUIData> +> + +export type ChatUIStructuredPart = StructuredOutputPart< + ChatUIData +> diff --git a/packages/ai-client/tests/ui-fixtures.ts b/packages/ai-client/tests/ui-fixtures.ts new file mode 100644 index 0000000000..19f833bc15 --- /dev/null +++ b/packages/ai-client/tests/ui-fixtures.ts @@ -0,0 +1,261 @@ +import { defineInterrupt, toolDefinition } from '@tanstack/ai/client' +import { z } from 'zod' +import type { ChatUIInterrupt } from '../src/ui' +import type { UIMessage } from '../src/types' + +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: 'Purchase 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 answerSchema = z.object({ answer: z.string() }) + +const stubConnection = { + connect: async function* () { + return + }, +} + +export const chatOptions = { + connection: stubConnection, + tools: [getWeather, purchaseItem], + interrupts: [choosePlan], + outputSchema: answerSchema, +} + +function noop(): void { + return +} + +export const approvalInterrupt = { + kind: 'tool-approval', + id: 'approval-1', + interruptId: 'approval-1', + reason: 'tool_call', + message: 'Approve purchase', + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 0, + status: 'pending', + errors: [], + canResolve: true, + toolName: 'purchaseItem', + toolCallId: 'call-weather', + originalArgs: { item: 'keyboard' }, + binding: { + kind: 'tool-approval', + toolCallId: 'call-weather', + toolName: 'purchaseItem', + }, + cancel: noop, + clearResolution: noop, + resolveInterrupt: noop, +} as unknown as ChatUIInterrupt + +export const genericInterrupt = { + kind: 'generic', + id: 'generic-1', + interruptId: 'generic-1', + reason: 'choose-plan', + message: 'Choose a plan', + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 0, + status: 'pending', + errors: [], + canResolve: true, + definitionId: 'choosePlan', + key: 'choosePlan', + payload: { title: 'Pro' }, + binding: { + kind: 'generic', + definitionId: 'choosePlan', + key: 'choosePlan', + batchIndex: 0, + }, + cancel: noop, + clearResolution: noop, + resolveInterrupt: noop, +} as unknown as ChatUIInterrupt + +export const unboundInterrupt = { + kind: 'unbound', + id: 'unbound-1', + interruptId: 'unbound-1', + reason: 'foreign', + message: 'Owned elsewhere', + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 0, + status: 'pending', + errors: [], + canResolve: false, +} as unknown as ChatUIInterrupt + +export const messageWithToolResults: UIMessage = { + id: 'message-1', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-weather', + name: 'getWeather', + arguments: '{"city":"Paris"}', + input: { city: 'Paris' }, + output: { temperature: 18 }, + state: 'complete', + }, + { + type: 'tool-result', + toolCallId: 'call-weather', + content: '{"temperature":18}', + state: 'complete', + }, + { + type: 'structured-output', + status: 'complete', + raw: '{"answer":"yes"}', + data: { answer: 'yes' }, + }, + ], +} + +export const unknownToolMessage: UIMessage = { + id: 'message-unknown', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-unknown', + name: 'notConfigured', + arguments: '{}', + state: 'complete', + }, + ], +} + +export const orphanResultMessage: UIMessage = { + id: 'message-orphan-result', + role: 'assistant', + parts: [ + { + type: 'text', + content: 'result follows', + }, + { + type: 'tool-result', + toolCallId: 'missing-call', + content: 'standalone', + state: 'complete', + }, + ], +} + +export const purchaseApprovalMessage: UIMessage = { + id: 'message-purchase', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-purchase', + name: 'purchaseItem', + arguments: '{"item":"keyboard"}', + input: { item: 'keyboard' }, + state: 'approval-requested', + approval: { + id: 'approval-purchase', + needsApproval: true, + }, + }, + ], +} + +export const purchaseApprovalInterrupt = { + ...approvalInterrupt, + id: 'approval-purchase', + interruptId: 'approval-purchase', + toolCallId: 'call-purchase', + binding: { + kind: 'tool-approval', + toolCallId: 'call-purchase', + toolName: 'purchaseItem', + }, +} as unknown as ChatUIInterrupt + +export function createChatResult(init: { + messages?: Array + interrupts?: Array + error?: Error + isLoading?: boolean + status?: 'ready' | 'submitted' | 'streaming' | 'error' +}) { + const interrupts = init.interrupts ?? [] + return { + messages: init.messages ?? [], + interrupts, + pendingInterrupts: interrupts, + error: init.error, + isLoading: init.isLoading ?? false, + status: init.status ?? 'ready', + sendMessage: async () => undefined, + stop: noop, + reload: async () => undefined, + clear: noop, + setMessages: noop, + } +} + +export function createSolidChatResult( + messages: Array, + interrupts: Array = [], +) { + const chat = createChatResult({ messages, interrupts }) + return { + ...chat, + messages: () => chat.messages, + interrupts: () => chat.interrupts, + pendingInterrupts: () => chat.pendingInterrupts, + error: () => chat.error, + isLoading: () => chat.isLoading, + status: () => chat.status, + } +} + +export function createVueChatResult( + messages: Array, + interrupts: Array = [], +) { + const chat = createChatResult({ messages, interrupts }) + return { + ...chat, + messages: { value: chat.messages }, + interrupts: { value: chat.interrupts }, + pendingInterrupts: { value: chat.pendingInterrupts }, + error: { value: chat.error }, + isLoading: { value: chat.isLoading }, + status: { value: chat.status }, + } +} + +export function createSvelteChatResult( + messages: Array, + interrupts: Array = [], +) { + return createChatResult({ messages, interrupts }) +} diff --git a/packages/ai-client/tests/ui-selectors.test.ts b/packages/ai-client/tests/ui-selectors.test.ts new file mode 100644 index 0000000000..12e590e91e --- /dev/null +++ b/packages/ai-client/tests/ui-selectors.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { + partTypeToKey, + resolveInterruptComponent, + selectChatUI, +} from '../src/ui' +import { + approvalInterrupt, + genericInterrupt, + messageWithToolResults, + orphanResultMessage, + purchaseApprovalInterrupt, + purchaseApprovalMessage, + unboundInterrupt, +} from './ui-fixtures' + +describe('selectChatUI', () => { + it('matches results and partitions interrupts', () => { + const selected = selectChatUI({ + messages: [messageWithToolResults], + interrupts: [approvalInterrupt, genericInterrupt], + inlineToolNames: ['purchaseItem'], + }) + + const parts = selected.messages[0]?.parts + expect(parts?.map((part) => part.key)).toEqual([ + 'toolCall', + 'toolResult', + 'structuredOutput', + ]) + + const call = parts?.[0] + expect(call?.key).toBe('toolCall') + if (call?.key === 'toolCall') { + expect(call.result?.toolCallId).toBe(call.part.id) + expect(call.interrupt).toBe(approvalInterrupt) + } + + expect(selected.interrupts).toEqual([genericInterrupt]) + }) + + it('uses camel-case component keys', () => { + expect(partTypeToKey('tool-call')).toBe('toolCall') + expect(partTypeToKey('tool-result')).toBe('toolResult') + expect(partTypeToKey('structured-output')).toBe('structuredOutput') + expect(partTypeToKey('ui-resource')).toBe('uiResource') + }) + + it('keeps unmatched tool-result parts', () => { + const selected = selectChatUI({ + messages: [orphanResultMessage], + interrupts: [], + }) + const resultPart = selected.messages[0]?.parts[1] + expect(resultPart?.key).toBe('toolResult') + if (resultPart?.key === 'toolResult') { + expect(resultPart.matched).toBe(false) + expect(resultPart.part.toolCallId).toBe('missing-call') + } + }) + + it('keeps orphan approvals in the interrupt list', () => { + const selected = selectChatUI({ + messages: [messageWithToolResults], + interrupts: [purchaseApprovalInterrupt], + inlineToolNames: ['purchaseItem'], + }) + expect(selected.interrupts).toEqual([purchaseApprovalInterrupt]) + }) + + it('attaches list-placement approvals to the call and keeps them in the list', () => { + const selected = selectChatUI({ + messages: [purchaseApprovalMessage], + interrupts: [purchaseApprovalInterrupt], + inlineToolNames: [], + }) + const call = selected.messages[0]?.parts[0] + expect(call?.key).toBe('toolCall') + if (call?.key === 'toolCall') { + expect(call.interrupt).toBe(purchaseApprovalInterrupt) + } + expect(selected.interrupts).toEqual([purchaseApprovalInterrupt]) + }) + + it('hides inline approvals from the interrupt list when a call matches', () => { + const selected = selectChatUI({ + messages: [purchaseApprovalMessage], + interrupts: [purchaseApprovalInterrupt, genericInterrupt], + inlineToolNames: ['purchaseItem'], + }) + expect(selected.interrupts).toEqual([genericInterrupt]) + }) + + it('preserves empty arrays', () => { + const selected = selectChatUI({ messages: [], interrupts: [] }) + expect(selected.messages).toEqual([]) + expect(selected.interrupts).toEqual([]) + }) + + it('preserves message and part order across every part key', () => { + const selected = selectChatUI({ + messages: [ + { + id: 'ordered', + role: 'assistant', + parts: [ + { type: 'thinking', content: 'hmm' }, + { type: 'text', content: 'hi' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/a.png' }, + }, + { + type: 'audio', + source: { type: 'url', value: 'https://example.com/a.wav' }, + }, + { + type: 'video', + source: { type: 'url', value: 'https://example.com/a.mp4' }, + }, + { + type: 'document', + source: { type: 'url', value: 'https://example.com/a.pdf' }, + }, + { + type: 'tool-call', + id: 'call-a', + name: 'getWeather', + arguments: '{}', + state: 'awaiting-input', + }, + { + type: 'tool-result', + toolCallId: 'other', + content: 'orphan', + state: 'complete', + }, + { + type: 'structured-output', + status: 'streaming', + raw: '{', + }, + { + type: 'ui-resource', + resource: { + uri: 'ui://widget', + mimeType: 'text/html', + text: '

', + }, + toolCallId: 'call-a', + toolName: 'getWeather', + }, + ], + }, + ], + }) + + expect(selected.messages[0]?.parts.map((part) => part.key)).toEqual([ + 'thinking', + 'text', + 'image', + 'audio', + 'video', + 'document', + 'toolCall', + 'toolResult', + 'structuredOutput', + 'uiResource', + ]) + }) + + it('keeps unbound and registered generic interrupts in the list', () => { + const selected = selectChatUI({ + messages: [], + interrupts: [genericInterrupt, unboundInterrupt], + }) + expect(selected.interrupts).toEqual([genericInterrupt, unboundInterrupt]) + }) +}) + +describe('resolveInterruptComponent', () => { + const Plan = { id: 'plan' } + const Fallback = { id: 'fallback' } + const Purchase = { id: 'purchase' } + const map = { + tools: { purchaseItem: Purchase }, + generic: { + choosePlan: Plan, + fallback: Fallback, + }, + } + + it('resolves a registered generic interrupt from generic[id]', () => { + expect(resolveInterruptComponent(genericInterrupt, map)).toBe(Plan) + }) + + it('resolves unbound through generic.fallback', () => { + expect(resolveInterruptComponent(unboundInterrupt, map)).toBe(Fallback) + }) + + it('resolves a tool approval from tools[name]', () => { + expect(resolveInterruptComponent(approvalInterrupt, map)).toBe(Purchase) + }) +}) diff --git a/packages/ai-client/tests/ui-types.test-d.ts b/packages/ai-client/tests/ui-types.test-d.ts new file mode 100644 index 0000000000..b8de8adb42 --- /dev/null +++ b/packages/ai-client/tests/ui-types.test-d.ts @@ -0,0 +1,23 @@ +import { expectTypeOf } from 'vitest' +import type { + ChatUIData, + ChatUIToolPart, + RegisteredUIInterrupt, +} from '../src/ui' +import { chatOptions } from './ui-fixtures' + +type WeatherPart = ChatUIToolPart +type PlanInterrupt = RegisteredUIInterrupt + +expectTypeOf().toEqualTypeOf< + { city: string } | undefined +>() +expectTypeOf().toEqualTypeOf< + { temperature: number } | undefined +>() +expectTypeOf().toEqualTypeOf< + { title: string } | undefined +>() +expectTypeOf>().toEqualTypeOf<{ + answer: string +}>() diff --git a/packages/ai-client/vite.config.ts b/packages/ai-client/vite.config.ts index 40c5f49c43..2e3210f99b 100644 --- a/packages/ai-client/vite.config.ts +++ b/packages/ai-client/vite.config.ts @@ -40,7 +40,12 @@ export default mergeConfig( // implementations; declare it as its own entry so the build emits // it independently and the main entry can stay free of the bridge // classes (they're imported only via `import type` from clients). - entry: ['./src/index.ts', './src/devtools.ts', './src/byok.ts'], + entry: [ + './src/index.ts', + './src/devtools.ts', + './src/byok.ts', + './src/ui.ts', + ], srcDir: './src', cjs: false, }), diff --git a/packages/ai-react-ui/src/chat-input.tsx b/packages/ai-react-ui/src/chat-input.tsx index d90cdcd5a9..f20e9e989b 100644 --- a/packages/ai-react-ui/src/chat-input.tsx +++ b/packages/ai-react-ui/src/chat-input.tsx @@ -31,6 +31,9 @@ export interface ChatInputProps { } /** + * @deprecated Use `createUI()` and an application-owned input component. + * Deprecated in 0.9.0. Removed in 1.0.0. + * * Chat input component - handles message input and submission * * Features: diff --git a/packages/ai-react-ui/src/chat-message.tsx b/packages/ai-react-ui/src/chat-message.tsx index 7665b1ba06..83ce913149 100644 --- a/packages/ai-react-ui/src/chat-message.tsx +++ b/packages/ai-react-ui/src/chat-message.tsx @@ -41,6 +41,8 @@ export interface ChatMessageProps { } /** + * @deprecated Use `createUI()` Message instead. Deprecated in 0.9.0. Removed in 1.0.0. + * * Message component - renders a single message with all its parts * * This component natively understands TanStack AI's parts-based message format: diff --git a/packages/ai-react-ui/src/chat-messages.tsx b/packages/ai-react-ui/src/chat-messages.tsx index 2cdf27b391..804abb34f8 100644 --- a/packages/ai-react-ui/src/chat-messages.tsx +++ b/packages/ai-react-ui/src/chat-messages.tsx @@ -4,6 +4,7 @@ import { ChatMessage } from './chat-message' import type { ReactNode } from 'react' import type { UIMessage } from '@tanstack/ai-react' +/** @deprecated Use `createUI()` Messages instead. Deprecated in 0.9.0. Removed in 1.0.0. */ export interface ChatMessagesProps { /** Custom render function for each message */ children?: (message: UIMessage, index: number) => ReactNode @@ -23,6 +24,8 @@ export interface ChatMessagesProps { } /** + * @deprecated Use `createUI()` Messages instead. Deprecated in 0.9.0. Removed in 1.0.0. + * * Messages container - renders all messages in the conversation * * @example diff --git a/packages/ai-react-ui/src/chat.tsx b/packages/ai-react-ui/src/chat.tsx index 09b00572ff..c0f20791f6 100644 --- a/packages/ai-react-ui/src/chat.tsx +++ b/packages/ai-react-ui/src/chat.tsx @@ -16,6 +16,7 @@ const ChatContext = createContext(null) * Hook to access chat context * @throws Error if used outside of Chat component */ +/** @deprecated Use `createUI().useChat()` instead. Deprecated in 0.9.0. Removed in 1.0.0. */ export function useChatContext() { const context = useContext(ChatContext) if (!context) { @@ -26,6 +27,7 @@ export function useChatContext() { return context } +/** @deprecated Use `createUI()` Chat/Provider instead. Deprecated in 0.9.0. Removed in 1.0.0. */ export interface ChatProps { /** Child components (Chat.Messages, Chat.Input, etc.) */ children: ReactNode @@ -52,6 +54,10 @@ export interface ChatProps { } /** + * @deprecated Use `createUI()` from `@tanstack/ai-react-ui` instead. + * See https://tanstack.com/ai/latest/docs/migration/create-ui + * Deprecated in 0.9.0. Removed in 1.0.0. + * * Root Chat component - provides context for all chat subcomponents * * @example diff --git a/packages/ai-react-ui/src/create-ui.tsx b/packages/ai-react-ui/src/create-ui.tsx new file mode 100644 index 0000000000..33813ba6e1 --- /dev/null +++ b/packages/ai-react-ui/src/create-ui.tsx @@ -0,0 +1,401 @@ +import { createContext, useContext } from 'react' +import type { ComponentType, ReactNode } from 'react' +import { + automaticPartsForMessage, + collectInlineToolNames, + resolveInterruptComponent, + selectChatUI, + selectMessageUI, +} from '@tanstack/ai-client/ui' +import type { + ChatUIData, + ChatUIInterrupt, + ChatUIPartKey, + ChatUIRegisteredInterruptId, + ChatUISelectedPart, + ChatUIToolName, + ChatUIToolsOf, + RegisteredUIInterrupt, +} from '@tanstack/ai-client/ui' +import type { + MessagePart, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai-client' + +// ponytail: duck-typed so UseChatReturn assigns; UIMessage generics are invariant +export type ChatUIHost = { + messages: ReadonlyArray + interrupts?: ReadonlyArray + error?: Error + isLoading?: boolean + status?: string + sendMessage?: (content: string, ...args: Array) => Promise | void +} + +export type LayoutProps = { + chat: ChatUIHost + renderMessages: () => ReactNode + renderInterrupts: () => ReactNode + renderInput: () => ReactNode + readonly __ui?: TOptions +} + +export type MessageProps = { + chat: ChatUIHost + message: UIMessage, ChatUIData> + renderParts: () => ReactNode +} + +export type InputProps = { + chat: ChatUIHost + readonly __ui?: TOptions +} + +export type PartProps = { + chat: ChatUIHost + part: MessagePart, ChatUIData> +} + +export type ToolProps< + TOptions, + TName extends ChatUIToolName = ChatUIToolName, +> = { + chat: ChatUIHost + part: Extract>, { name: TName }> + result?: ToolResultPart + interrupt?: Extract< + ChatUIInterrupt, + { kind: 'tool-approval'; toolName: TName } + > + renderInterrupt: () => ReactNode +} + +export type InterruptProps = { + chat: ChatUIHost + interrupt: ChatUIInterrupt + readonly __ui?: TOptions +} + +export type RegisteredInterruptProps< + TOptions, + TId extends ChatUIRegisteredInterruptId = + ChatUIRegisteredInterruptId, +> = { + chat: ChatUIHost + interrupt: RegisteredUIInterrupt +} + +type InterruptEntry = + | ComponentType> + | { + component: ComponentType> + placement?: 'inline' | 'list' + } + +type GenericInterruptComponents = { + [K in ChatUIRegisteredInterruptId as K extends 'fallback' + ? never + : K]?: ComponentType> +} & { + fallback?: ComponentType> +} + +export type ChatUIComponents = { + layout: ComponentType> + message: ComponentType> + input?: ComponentType> + parts: { + [K in ChatUIPartKey]?: ComponentType> + } & { + fallback?: ComponentType> + } + tools?: { + [K in ChatUIToolName]?: ComponentType> + } + interrupts?: { + tools?: { + [K in ChatUIToolName]?: InterruptEntry + } + generic?: GenericInterruptComponents + } +} + +type UIContextValue = { + chat: ChatUIHost + components: ChatUIComponents + warn: (key: string, message: string) => void +} + +function createWarnOnce() { + const seen = new Set() + return (key: string, message: string) => { + if (process.env.NODE_ENV === 'production') return + if (seen.has(key)) return + seen.add(key) + console.warn(message) + } +} + +function readInterrupts(chat: ChatUIHost) { + return chat.interrupts ?? [] +} + +export function createUI(options: TOptions) { + void options + const warn = createWarnOnce() + const UIContext = createContext | null>(null) + + function useUIContext() { + const value = useContext(UIContext) + if (!value) { + throw new Error( + 'Chat UI components must be wrapped in UI.Provider or UI.Chat.', + ) + } + return value + } + + function useChat() { + return useUIContext().chat + } + + function defineComponents( + components: ChatUIComponents, + ): ChatUIComponents { + return components + } + + function inlineNames(components: ChatUIComponents) { + return collectInlineToolNames( + components.interrupts?.tools as Record | undefined, + ) + } + + function Provider({ + chat, + components, + children, + }: { + chat: ChatUIHost + components: ChatUIComponents + children?: ReactNode + }) { + return ( + + {children} + + ) + } + + function Chat({ + chat, + components, + }: { + chat: ChatUIHost + components: ChatUIComponents + }) { + const Layout = components.layout + return ( + + } + renderInterrupts={() => } + renderInput={() => { + const Input = components.input + return Input ? : null + }} + /> + + ) + } + + function Messages({ + children, + }: { + children?: (messages: ChatUIHost['messages']) => ReactNode + } = {}) { + const { chat } = useUIContext() + if (children) return <>{children(chat.messages)} + return ( + <> + {chat.messages.map((message) => ( + + ))} + + ) + } + + function Message({ + message, + children, + }: { + message: ChatUIHost['messages'][number] + children?: (parts: Array) => ReactNode + }) { + const { chat, components } = useUIContext() + const inlineToolNames = inlineNames(components) + const selected = selectMessageUI(message, { + interrupts: readInterrupts(chat), + inlineToolNames, + }) + if (children) return <>{children(selected.parts)} + const MessageComponent = components.message + return ( + } + /> + ) + } + + function AutomaticParts({ + message, + }: { + message: ChatUIHost['messages'][number] + }) { + const { chat, components } = useUIContext() + const inlineToolNames = inlineNames(components) + const selected = selectMessageUI(message, { + interrupts: readInterrupts(chat), + inlineToolNames, + }) + return ( + <> + {automaticPartsForMessage(selected).map((part, index) => ( + + ))} + + ) + } + + function SelectedPartView({ + selected, + inline, + }: { + selected: ChatUISelectedPart + inline: boolean + }) { + const { chat, components, warn: warnMissing } = useUIContext() + if (selected.key === 'toolCall') { + const name = selected.part.name + const Tool = components.tools?.[name as ChatUIToolName] as + | ComponentType> + | undefined + if (!Tool) { + warnMissing( + `tool:${name}`, + `[tanstack-ai-ui] Missing tools.${name} component`, + ) + return null + } + return ( + ['part']} + result={selected.result} + interrupt={selected.interrupt as ToolProps['interrupt']} + renderInterrupt={() => + inline ? : null + } + /> + ) + } + + const PartComponent = + components.parts[selected.key] ?? components.parts.fallback + if (!PartComponent) { + warnMissing( + `part:${selected.key}`, + `[tanstack-ai-ui] Missing parts.${selected.key} component`, + ) + return null + } + return ( + ['part']} + /> + ) + } + + function InlineInterrupt({ interrupt }: { interrupt?: ChatUIInterrupt }) { + const { chat, components } = useUIContext() + if (!interrupt || interrupt.kind !== 'tool-approval') return null + const inlineToolNames = inlineNames(components) + if (!inlineToolNames.includes(interrupt.toolName)) return null + const Component = resolveInterruptComponent( + interrupt, + components.interrupts, + ) as ComponentType> | undefined + if (!Component) return null + return + } + + function Part({ part }: { part: MessagePart }) { + const { chat } = useUIContext() + const selected = selectMessageUI( + { id: 'part', role: 'assistant', parts: [part] }, + { interrupts: readInterrupts(chat), inlineToolNames: [] }, + ).parts[0] + if (!selected) return null + return + } + + function Interrupts({ + children, + }: { + children?: (interrupts: ReadonlyArray) => ReactNode + } = {}) { + const { chat, components } = useUIContext() + const inlineToolNames = inlineNames(components) + const selected = selectChatUI({ + messages: chat.messages, + interrupts: readInterrupts(chat), + inlineToolNames, + }) + if (children) return <>{children(selected.interrupts)} + return ( + <> + {selected.interrupts.map((interrupt) => ( + + ))} + + ) + } + + function Interrupt({ interrupt }: { interrupt: ChatUIInterrupt }) { + const { chat, components, warn: warnMissing } = useUIContext() + const Component = resolveInterruptComponent( + interrupt, + components.interrupts, + ) as ComponentType> | undefined + if (!Component) { + warnMissing( + `interrupt:${interrupt.id}`, + `[tanstack-ai-ui] Missing interrupt component for ${interrupt.kind}`, + ) + return null + } + return + } + + return { + Chat, + Provider, + Messages, + Message, + Part, + Interrupts, + Interrupt, + defineComponents, + useChat, + } +} diff --git a/packages/ai-react-ui/src/index.ts b/packages/ai-react-ui/src/index.ts index eec1ce48e4..b2e71e47b6 100644 --- a/packages/ai-react-ui/src/index.ts +++ b/packages/ai-react-ui/src/index.ts @@ -24,6 +24,19 @@ * ``` */ +export { + createUI, + type ChatUIComponents, + type ChatUIHost, + type InputProps, + type InterruptProps, + type LayoutProps, + type MessageProps, + type PartProps, + type RegisteredInterruptProps, + type ToolProps, +} from './create-ui' + // Main components export { Chat, useChatContext, type ChatProps } from './chat' export { ChatMessages, type ChatMessagesProps } from './chat-messages' diff --git a/packages/ai-react-ui/src/tool-approval.tsx b/packages/ai-react-ui/src/tool-approval.tsx index 2c0707aedf..c079ae8aa4 100644 --- a/packages/ai-react-ui/src/tool-approval.tsx +++ b/packages/ai-react-ui/src/tool-approval.tsx @@ -36,6 +36,9 @@ export interface ToolApprovalRenderProps { } /** + * @deprecated Use `createUI()` interrupt components with `chat.interrupts`. + * Deprecated in 0.9.0. Removed in 1.0.0. + * * Tool approval component - renders approve/deny buttons for tools that need approval * * @example diff --git a/packages/ai-react-ui/tests/coexistence.test.ts b/packages/ai-react-ui/tests/coexistence.test.ts new file mode 100644 index 0000000000..7391e81b02 --- /dev/null +++ b/packages/ai-react-ui/tests/coexistence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + Chat, + ChatInput, + ChatMessage, + ChatMessages, + TextPart, + ThinkingPart, + createUI, +} from '../src' + +describe('public coexistence', () => { + it('exports old and new APIs before 1.0', () => { + expect(Chat).toBeDefined() + expect(ChatInput).toBeDefined() + expect(ChatMessage).toBeDefined() + expect(ChatMessages).toBeDefined() + expect(TextPart).toBeDefined() + expect(ThinkingPart).toBeDefined() + expect(createUI).toBeDefined() + }) +}) diff --git a/packages/ai-react-ui/tests/create-ui-types.test.tsx b/packages/ai-react-ui/tests/create-ui-types.test.tsx new file mode 100644 index 0000000000..036d2b2369 --- /dev/null +++ b/packages/ai-react-ui/tests/create-ui-types.test.tsx @@ -0,0 +1,57 @@ +import { expectTypeOf, it } from 'vitest' +import { createUI } from '../src/create-ui' +import type { RegisteredInterruptProps, ToolProps } from '../src/create-ui' +import { chatOptions } from '../../ai-client/tests/ui-fixtures' + +it('types tool and interrupt component props from chatOptions', () => { + type WeatherToolProps = ToolProps + expectTypeOf().toEqualTypeOf< + { city: string } | undefined + >() + + type ChoosePlanProps = RegisteredInterruptProps< + typeof chatOptions, + 'choosePlan' + > + expectTypeOf().toEqualTypeOf< + { title: string } | undefined + >() + + const UI = createUI(chatOptions) + + UI.defineComponents({ + layout: ({ chat, renderMessages }) => { + expectTypeOf(chat.messages).toMatchTypeOf>() + return renderMessages() + }, + message: ({ renderParts }) => renderParts(), + parts: { + fallback: () => null, + }, + tools: { + getWeather: ({ part, result }) => { + expectTypeOf(part.input).toEqualTypeOf<{ city: string } | undefined>() + expectTypeOf(part.output).toEqualTypeOf< + { temperature: number } | undefined + >() + expectTypeOf(result?.toolCallId).toEqualTypeOf() + return null + }, + // @ts-expect-error This tool is not in chatOptions. + unknownTool: () => null, + }, + interrupts: { + generic: { + choosePlan: ({ interrupt }) => { + interrupt.resolveInterrupt('approved') + // @ts-expect-error The response schema accepts a string. + interrupt.resolveInterrupt(42) + return null + }, + // @ts-expect-error This interrupt is not in chatOptions. + unknownInterrupt: () => null, + fallback: () => null, + }, + }, + }) +}) diff --git a/packages/ai-react-ui/tests/create-ui.test.tsx b/packages/ai-react-ui/tests/create-ui.test.tsx new file mode 100644 index 0000000000..1fb766d64a --- /dev/null +++ b/packages/ai-react-ui/tests/create-ui.test.tsx @@ -0,0 +1,310 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { createUI } from '../src/create-ui' +import { + chatOptions, + createChatResult, + genericInterrupt, + messageWithToolResults, + orphanResultMessage, + purchaseApprovalInterrupt, + purchaseApprovalMessage, + unboundInterrupt, + unknownToolMessage, +} from '../../ai-client/tests/ui-fixtures' +import type { ToolCallState } from '@tanstack/ai-client' + +describe('createUI', () => { + it('renders automatic and manual trees', () => { + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( + <> + {renderMessages()} + {renderInterrupts()} + + ), + message: ({ renderParts }) =>
{renderParts()}
, + parts: { fallback: ({ part }) => {part.type} }, + tools: { + getWeather: ({ part }) => {part.input?.city}, + purchaseItem: () => null, + }, + interrupts: { + generic: { fallback: ({ interrupt }) => {interrupt.reason} }, + }, + }) + + const chat = createChatResult({ messages: [messageWithToolResults] }) + const automatic = renderToStaticMarkup( + , + ) + expect(automatic).toContain('Paris') + + const manual = renderToStaticMarkup( + + + {(messages) => {messages.length}} + + , + ) + expect(manual).toContain('1') + }) + + it('warns once for a missing runtime key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const UI = createUI(chatOptions) + const chat = createChatResult({ messages: [unknownToolMessage] }) + const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: () => null, + }, + interrupts: { generic: { fallback: () => null } }, + }) + renderToStaticMarkup() + renderToStaticMarkup() + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('keeps unmatched tool results and suppresses matched ones', () => { + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) => renderParts(), + parts: { + toolResult: ({ part }) => + part.type === 'tool-result' ? {String(part.content)} : null, + fallback: () => null, + }, + tools: { + getWeather: () => weather, + purchaseItem: () => null, + }, + interrupts: { generic: { fallback: () => null } }, + }) + + const matched = renderToStaticMarkup( + , + ) + expect(matched).toContain('weather') + expect(matched).not.toContain('') + + const unmatched = renderToStaticMarkup( + , + ) + expect(unmatched).toContain('standalone') + }) + + it('puts inline approvals in the tool slot and list approvals in Interrupts', () => { + const UI = createUI(chatOptions) + const inline = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( + <> + {renderMessages()} + {renderInterrupts()} + + ), + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: ({ renderInterrupt }) => ( +
+ tool + {renderInterrupt()} +
+ ), + }, + interrupts: { + tools: { + purchaseItem: { + component: () => inline-approval, + placement: 'inline', + }, + }, + generic: { fallback: ({ interrupt }) => {interrupt.reason} }, + }, + }) + + const inlineMarkup = renderToStaticMarkup( + , + ) + expect(inlineMarkup).toContain('inline-approval') + expect(inlineMarkup).not.toContain('') + + const list = UI.defineComponents({ + layout: ({ renderMessages, renderInterrupts }) => ( + <> + {renderMessages()} + {renderInterrupts()} + + ), + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: ({ renderInterrupt }) => ( +
+ tool + {renderInterrupt()} +
+ ), + }, + interrupts: { + tools: { + purchaseItem: () => list-approval, + }, + generic: { fallback: () => null }, + }, + }) + + const listMarkup = renderToStaticMarkup( + , + ) + expect(listMarkup).toContain('list-approval') + }) + + it('renders registered generic interrupts and sends the rest to fallback', () => { + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: ({ renderInterrupts }) => renderInterrupts(), + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: () => null, + }, + interrupts: { + generic: { + choosePlan: () => plan, + fallback: () => fallback, + }, + }, + }) + + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('plan') + expect(markup).toContain('fallback') + }) + + it('omits input when no input component exists', () => { + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: ({ renderInput }) =>
{renderInput()}
, + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toBe('
') + }) + + it('reads chat from nested provider context and throws outside a provider', () => { + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: () => { + const chat = UI.useChat() + return

{chat.messages.length}

+ }, + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + }) + + const inner = createChatResult({ messages: [messageWithToolResults] }) + const outer = createChatResult({ messages: [] }) + const markup = renderToStaticMarkup( + + + , + ) + expect(markup).toContain('

1

') + + function Broken() { + UI.useChat() + return null + } + expect(() => renderToStaticMarkup()).toThrow( + /UI.Provider or UI.Chat/, + ) + }) + + it('renders a tool component for every ToolCallState', () => { + const states: Array = [ + 'awaiting-input', + 'input-streaming', + 'input-complete', + 'approval-requested', + 'approval-responded', + 'complete', + 'error', + ] + const UI = createUI(chatOptions) + const components = UI.defineComponents({ + layout: ({ renderMessages }) => renderMessages(), + message: ({ renderParts }) => renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: ({ part }) => {part.state}, + purchaseItem: () => null, + }, + interrupts: { generic: { fallback: () => null } }, + }) + + for (const state of states) { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(`${state}`) + } + }) +}) diff --git a/packages/ai-react-ui/tests/react-dom-server.d.ts b/packages/ai-react-ui/tests/react-dom-server.d.ts new file mode 100644 index 0000000000..df9bff243d --- /dev/null +++ b/packages/ai-react-ui/tests/react-dom-server.d.ts @@ -0,0 +1,4 @@ +declare module 'react-dom/server' { + import type { ReactNode } from 'react' + export function renderToStaticMarkup(node: ReactNode): string +} diff --git a/packages/ai-react-ui/tsconfig.json b/packages/ai-react-ui/tsconfig.json index 65915cd005..d3e610bff5 100644 --- a/packages/ai-react-ui/tsconfig.json +++ b/packages/ai-react-ui/tsconfig.json @@ -2,12 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", "jsx": "react-jsx", - "declarationMap": true, - "composite": true, - "lib": ["ES2022", "DOM"] + "lib": ["ES2022", "DOM"], + "rootDir": "../.." }, - "include": ["src"], + "include": ["src", "tests"], "exclude": ["node_modules", "dist"] } diff --git a/packages/ai-react-ui/vite.config.ts b/packages/ai-react-ui/vite.config.ts index 77bcc2e60b..782c82ec49 100644 --- a/packages/ai-react-ui/vite.config.ts +++ b/packages/ai-react-ui/vite.config.ts @@ -9,7 +9,7 @@ const config = defineConfig({ watch: false, globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], diff --git a/packages/ai-solid-ui/src/chat-input.tsx b/packages/ai-solid-ui/src/chat-input.tsx index b720b9c1cc..7be552ad9e 100644 --- a/packages/ai-solid-ui/src/chat-input.tsx +++ b/packages/ai-solid-ui/src/chat-input.tsx @@ -17,6 +17,7 @@ export interface ChatInputRenderProps { ref: (el: HTMLInputElement | HTMLTextAreaElement) => void } +/** @deprecated Use `createUI()` and an application-owned input component. Deprecated in 0.8.0. Removed in 1.0.0. */ export interface ChatInputProps { /** Render prop for full control */ children?: (props: ChatInputRenderProps) => JSX.Element @@ -31,6 +32,8 @@ export interface ChatInputProps { } /** + * @deprecated Use `createUI()` and an application-owned input component. Deprecated in 0.8.0. Removed in 1.0.0. + * * Chat input component - handles message input and submission * * Features: diff --git a/packages/ai-solid-ui/src/chat-message.tsx b/packages/ai-solid-ui/src/chat-message.tsx index 10b3c5715e..1f623cc33b 100644 --- a/packages/ai-solid-ui/src/chat-message.tsx +++ b/packages/ai-solid-ui/src/chat-message.tsx @@ -12,6 +12,7 @@ export interface ToolCallRenderProps { output?: any } +/** @deprecated Use `createUI()` Message instead. Deprecated in 0.8.0. Removed in 1.0.0. */ export interface ChatMessageProps { /** The message to render */ message: UIMessage @@ -41,6 +42,8 @@ export interface ChatMessageProps { } /** + * @deprecated Use `createUI()` Message instead. Deprecated in 0.8.0. Removed in 1.0.0. + * * Message component - renders a single message with all its parts * * This component natively understands TanStack AI's parts-based message format: diff --git a/packages/ai-solid-ui/src/chat-messages.tsx b/packages/ai-solid-ui/src/chat-messages.tsx index c0136696a3..071d4ef50e 100644 --- a/packages/ai-solid-ui/src/chat-messages.tsx +++ b/packages/ai-solid-ui/src/chat-messages.tsx @@ -4,6 +4,7 @@ import { ChatMessage } from './chat-message' import type { JSX } from 'solid-js' import type { UIMessage } from '@tanstack/ai-solid' +/** @deprecated Use `createUI()` Messages instead. Deprecated in 0.8.0. Removed in 1.0.0. */ export interface ChatMessagesProps { /** Custom render function for each message */ children?: (message: UIMessage, index: number) => JSX.Element @@ -23,6 +24,8 @@ export interface ChatMessagesProps { } /** + * @deprecated Use `createUI()` Messages instead. Deprecated in 0.8.0. Removed in 1.0.0. + * * Messages container - renders all messages in the conversation * * @example diff --git a/packages/ai-solid-ui/src/chat.tsx b/packages/ai-solid-ui/src/chat.tsx index 2a94bcd9d0..dda77eb0a4 100644 --- a/packages/ai-solid-ui/src/chat.tsx +++ b/packages/ai-solid-ui/src/chat.tsx @@ -16,6 +16,7 @@ const ChatContext = createContext(null) * Hook to access chat context * @throws Error if used outside of Chat component */ +/** @deprecated Use `createUI().useChat()` instead. Deprecated in 0.8.0. Removed in 1.0.0. */ export function useChatContext(): UseChatReturn { const context = useContext(ChatContext) if (!context) { @@ -26,6 +27,7 @@ export function useChatContext(): UseChatReturn { return context } +/** @deprecated Use `createUI()` Chat/Provider instead. Deprecated in 0.8.0. Removed in 1.0.0. */ export interface ChatProps { /** Child components (Chat.Messages, Chat.Input, etc.) */ children: JSX.Element @@ -57,6 +59,10 @@ export interface ChatProps { } /** + * @deprecated Use `createUI()` from `@tanstack/ai-solid-ui` instead. + * See https://tanstack.com/ai/latest/docs/migration/create-ui + * Deprecated in 0.8.0. Removed in 1.0.0. + * * Root Chat component - provides context for all chat subcomponents * * @example diff --git a/packages/ai-solid-ui/src/create-ui.tsx b/packages/ai-solid-ui/src/create-ui.tsx new file mode 100644 index 0000000000..d36217ff07 --- /dev/null +++ b/packages/ai-solid-ui/src/create-ui.tsx @@ -0,0 +1,398 @@ +import { createContext, useContext } from 'solid-js' +import type { Accessor, Component, JSX } from 'solid-js' +import { + automaticPartsForMessage, + collectInlineToolNames, + resolveInterruptComponent, + selectChatUI, + selectMessageUI, +} from '@tanstack/ai-client/ui' +import type { + ChatUIData, + ChatUIInterrupt, + ChatUIPartKey, + ChatUIRegisteredInterruptId, + ChatUISelectedPart, + ChatUIToolName, + ChatUIToolsOf, + RegisteredUIInterrupt, +} from '@tanstack/ai-client/ui' +import type { + MessagePart, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai-client' + +// ponytail: duck-typed so UseChatReturn assigns; accessors and UIMessage generics are invariant +export type ChatUIHost = { + messages: Accessor> | ReadonlyArray + interrupts?: Accessor> | ReadonlyArray + error?: Accessor | Error + isLoading?: Accessor | boolean + status?: Accessor | string + sendMessage?: (content: string, ...args: Array) => Promise | void +} + +export type LayoutProps = { + chat: ChatUIHost + renderMessages: () => JSX.Element + renderInterrupts: () => JSX.Element + renderInput: () => JSX.Element + readonly __ui?: TOptions +} + +export type MessageProps = { + chat: ChatUIHost + message: UIMessage, ChatUIData> + renderParts: () => JSX.Element +} + +export type InputProps = { + chat: ChatUIHost + readonly __ui?: TOptions +} + +export type PartProps = { + chat: ChatUIHost + part: MessagePart, ChatUIData> +} + +export type ToolProps< + TOptions, + TName extends ChatUIToolName = ChatUIToolName, +> = { + chat: ChatUIHost + part: Extract>, { name: TName }> + result?: ToolResultPart + interrupt?: Extract< + ChatUIInterrupt, + { kind: 'tool-approval'; toolName: TName } + > + renderInterrupt: () => JSX.Element +} + +export type InterruptProps = { + chat: ChatUIHost + interrupt: ChatUIInterrupt + readonly __ui?: TOptions +} + +export type RegisteredInterruptProps< + TOptions, + TId extends ChatUIRegisteredInterruptId = + ChatUIRegisteredInterruptId, +> = { + chat: ChatUIHost + interrupt: RegisteredUIInterrupt +} + +type InterruptEntry = + | Component> + | { + component: Component> + placement?: 'inline' | 'list' + } + +type GenericInterruptComponents = { + [K in ChatUIRegisteredInterruptId as K extends 'fallback' + ? never + : K]?: Component> +} & { + fallback?: Component> +} + +export type ChatUIComponents = { + layout: Component> + message: Component> + input?: Component> + parts: { + [K in ChatUIPartKey]?: Component> + } & { + fallback?: Component> + } + tools?: { + [K in ChatUIToolName]?: Component> + } + interrupts?: { + tools?: { + [K in ChatUIToolName]?: InterruptEntry + } + generic?: GenericInterruptComponents + } +} + +type UIContextValue = { + chat: ChatUIHost + components: ChatUIComponents + warn: (key: string, message: string) => void +} + +function createWarnOnce() { + const seen = new Set() + return (key: string, message: string) => { + if (process.env.NODE_ENV === 'production') return + if (seen.has(key)) return + seen.add(key) + console.warn(message) + } +} + +function readMessages(chat: ChatUIHost): ReadonlyArray { + return typeof chat.messages === 'function' ? chat.messages() : chat.messages +} + +function readInterrupts(chat: ChatUIHost): ReadonlyArray { + const value = chat.interrupts + if (typeof value === 'function') return value() + return value ?? [] +} + +function messagesAccessor(chat: ChatUIHost): Accessor> { + const value = chat.messages + if (typeof value === 'function') return value + return () => value +} + +export function createUI(options: TOptions) { + void options + const warn = createWarnOnce() + const UIContext = createContext | null>(null) + + function useUIContext() { + const value = useContext(UIContext) + if (!value) { + throw new Error( + 'Chat UI components must be wrapped in UI.Provider or UI.Chat.', + ) + } + return value + } + + function useChat() { + return useUIContext().chat + } + + function defineComponents(components: ChatUIComponents) { + return components + } + + function inlineNames(components: ChatUIComponents) { + return collectInlineToolNames( + components.interrupts?.tools as Record | undefined, + ) + } + + function Provider(props: { + chat: ChatUIHost + components: ChatUIComponents + children?: JSX.Element + }) { + return ( + + {props.children} + + ) + } + + function Chat(props: { + chat: ChatUIHost + components: ChatUIComponents + }) { + const Layout = props.components.layout + return ( + + } + renderInterrupts={() => } + renderInput={() => { + const Input = props.components.input + return Input ? : null + }} + /> + + ) + } + + function Messages(props: { + children?: ( + messages: Accessor>>, + ) => JSX.Element + }) { + const ctx = useUIContext() + if (props.children) return <>{props.children(messagesAccessor(ctx.chat))} + return ( + <> + {readMessages(ctx.chat).map((message) => ( + + ))} + + ) + } + + function Message(props: { + message: UIMessage + children?: (parts: Array) => JSX.Element + }) { + const ctx = useUIContext() + const inlineToolNames = inlineNames(ctx.components) + const selected = selectMessageUI(props.message, { + interrupts: readInterrupts(ctx.chat), + inlineToolNames, + }) + if (props.children) return <>{props.children(selected.parts)} + const MessageComponent = ctx.components.message + return ( + ['message']} + renderParts={() => } + /> + ) + } + + function AutomaticParts(props: { message: UIMessage }) { + const ctx = useUIContext() + const selected = selectMessageUI(props.message, { + interrupts: readInterrupts(ctx.chat), + inlineToolNames: inlineNames(ctx.components), + }) + return ( + <> + {automaticPartsForMessage(selected).map((part) => ( + + ))} + + ) + } + + function SelectedPartView(props: { + selected: ChatUISelectedPart + inline: boolean + }) { + const ctx = useUIContext() + if (props.selected.key === 'toolCall') { + const name = props.selected.part.name + const Tool = ctx.components.tools?.[name as ChatUIToolName] as + | Component> + | undefined + if (!Tool) { + ctx.warn( + `tool:${name}`, + `[tanstack-ai-ui] Missing tools.${name} component`, + ) + return null + } + return ( + ['part']} + result={props.selected.result} + interrupt={ + (props.selected.key === 'toolCall' + ? props.selected.interrupt + : undefined) as ToolProps['interrupt'] + } + renderInterrupt={() => + props.inline && props.selected.key === 'toolCall' ? ( + + ) : null + } + /> + ) + } + const PartComponent = + ctx.components.parts[props.selected.key] ?? ctx.components.parts.fallback + if (!PartComponent) { + ctx.warn( + `part:${props.selected.key}`, + `[tanstack-ai-ui] Missing parts.${props.selected.key} component`, + ) + return null + } + return ( + ['part']} + /> + ) + } + + function InlineInterrupt(props: { interrupt?: ChatUIInterrupt }) { + const ctx = useUIContext() + const interrupt = props.interrupt + if (!interrupt || interrupt.kind !== 'tool-approval') return null + if (!inlineNames(ctx.components).includes(interrupt.toolName)) return null + const Component = resolveInterruptComponent( + interrupt, + ctx.components.interrupts, + ) as Component> | undefined + if (!Component) return null + return + } + + function Part(props: { part: MessagePart }) { + const ctx = useUIContext() + const selected = selectMessageUI( + { id: 'part', role: 'assistant', parts: [props.part] }, + { interrupts: readInterrupts(ctx.chat), inlineToolNames: [] }, + ).parts[0] + if (!selected) return null + return + } + + function Interrupts(props: { + children?: ( + interrupts: Accessor>, + ) => JSX.Element + }) { + const ctx = useUIContext() + const selected = () => + selectChatUI({ + messages: readMessages(ctx.chat), + interrupts: readInterrupts(ctx.chat), + inlineToolNames: inlineNames(ctx.components), + }) + if (props.children) { + return <>{props.children(() => selected().interrupts)} + } + return ( + <> + {selected().interrupts.map((interrupt) => ( + + ))} + + ) + } + + function Interrupt(props: { interrupt: ChatUIInterrupt }) { + const ctx = useUIContext() + const Component = resolveInterruptComponent( + props.interrupt, + ctx.components.interrupts, + ) as Component> | undefined + if (!Component) { + ctx.warn( + `interrupt:${props.interrupt.id}`, + `[tanstack-ai-ui] Missing interrupt component for ${props.interrupt.kind}`, + ) + return null + } + return + } + + return { + Chat, + Provider, + Messages, + Message, + Part, + Interrupts, + Interrupt, + defineComponents, + useChat, + } +} diff --git a/packages/ai-solid-ui/src/index.ts b/packages/ai-solid-ui/src/index.ts index 415bdd200b..434411c513 100644 --- a/packages/ai-solid-ui/src/index.ts +++ b/packages/ai-solid-ui/src/index.ts @@ -24,6 +24,19 @@ * ``` */ +export { + createUI, + type ChatUIComponents, + type ChatUIHost, + type InputProps, + type InterruptProps, + type LayoutProps, + type MessageProps, + type PartProps, + type RegisteredInterruptProps, + type ToolProps, +} from './create-ui' + // Main components export { Chat, useChatContext, type ChatProps } from './chat' export { ChatMessages, type ChatMessagesProps } from './chat-messages' diff --git a/packages/ai-solid-ui/src/tool-approval.tsx b/packages/ai-solid-ui/src/tool-approval.tsx index 51129ff264..79f1f0f694 100644 --- a/packages/ai-solid-ui/src/tool-approval.tsx +++ b/packages/ai-solid-ui/src/tool-approval.tsx @@ -1,6 +1,7 @@ import { useChatContext } from './chat' import type { JSX } from 'solid-js' +/** @deprecated Use `createUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0. */ export interface ToolApprovalProps { /** Tool call ID */ toolCallId: string @@ -36,6 +37,8 @@ export interface ToolApprovalRenderProps { } /** + * @deprecated Use `createUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0. + * * Tool approval component - renders approve/deny buttons for tools that need approval * * @example diff --git a/packages/ai-solid-ui/tests/coexistence.test.ts b/packages/ai-solid-ui/tests/coexistence.test.ts new file mode 100644 index 0000000000..86bc7944d8 --- /dev/null +++ b/packages/ai-solid-ui/tests/coexistence.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { createUI } from '../src/create-ui' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const src = dirname(fileURLToPath(import.meta.url)) +const index = readFileSync(join(src, '../src/index.ts'), 'utf8') + +describe('public coexistence', () => { + it('exports old and new APIs before 1.0', () => { + expect(createUI).toBeDefined() + expect(index).toContain('export { Chat') + expect(index).toContain('ChatInput') + expect(index).toContain('ChatMessage') + expect(index).toContain('ChatMessages') + expect(index).toContain('TextPart') + expect(index).toContain('ThinkingPart') + expect(index).toContain('createUI') + }) +}) diff --git a/packages/ai-solid-ui/tests/create-ui-types.test.tsx b/packages/ai-solid-ui/tests/create-ui-types.test.tsx new file mode 100644 index 0000000000..09f679d0dd --- /dev/null +++ b/packages/ai-solid-ui/tests/create-ui-types.test.tsx @@ -0,0 +1,40 @@ +import { expectTypeOf } from 'vitest' +import { createUI } from '../src/create-ui' +import { chatOptions } from '../../ai-client/tests/ui-fixtures' + +const UI = createUI(chatOptions) + +UI.defineComponents({ + layout: (props) => { + expectTypeOf(props.chat.messages).toMatchTypeOf() + return props.renderMessages() + }, + message: (props) => props.renderParts(), + parts: { + fallback: () => null, + }, + tools: { + getWeather: (props) => { + expectTypeOf(props.part.input).toEqualTypeOf< + { city: string } | undefined + >() + expectTypeOf(props.part.output).toEqualTypeOf< + { temperature: number } | undefined + >() + return null + }, + // @ts-expect-error This tool is not in chatOptions. + unknownTool: () => null, + }, + interrupts: { + generic: { + choosePlan: (props) => { + props.interrupt.resolveInterrupt('approved') + // @ts-expect-error The response schema accepts a string. + props.interrupt.resolveInterrupt(42) + return null + }, + fallback: () => null, + }, + }, +}) diff --git a/packages/ai-solid-ui/tests/create-ui.test.tsx b/packages/ai-solid-ui/tests/create-ui.test.tsx new file mode 100644 index 0000000000..1722a2c474 --- /dev/null +++ b/packages/ai-solid-ui/tests/create-ui.test.tsx @@ -0,0 +1,65 @@ +import { render } from 'solid-js/web' +import { describe, expect, it, vi } from 'vitest' +import { createUI } from '../src/create-ui' +import { + chatOptions, + createSolidChatResult, + messageWithToolResults, + unknownToolMessage, +} from '../../ai-client/tests/ui-fixtures' + +function renderHtml(node: () => unknown) { + const container = document.createElement('div') + render(node as () => import('solid-js').JSX.Element, container) + return container.innerHTML +} + +describe('Solid createUI', () => { + it('renders automatic and manual traversal', () => { + const UI = createUI(chatOptions) + const chat = createSolidChatResult([messageWithToolResults]) + const components = UI.defineComponents({ + layout: (props) => <>{props.renderMessages()}, + message: (props) =>
{props.renderParts()}
, + parts: { fallback: (props) => {props.part.type} }, + tools: { + getWeather: (props) => {props.part.input?.city}, + purchaseItem: () => null, + }, + interrupts: { generic: { fallback: () => null } }, + }) + + expect( + renderHtml(() => ), + ).toContain('Paris') + expect( + renderHtml(() => ( + + + {(messages) => {messages().length}} + + + )), + ).toContain('1') + }) + + it('warns once for a missing runtime key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const UI = createUI(chatOptions) + const chat = createSolidChatResult([unknownToolMessage]) + const components = UI.defineComponents({ + layout: (props) => props.renderMessages(), + message: (props) => props.renderParts(), + parts: { fallback: () => null }, + tools: { + getWeather: () => null, + purchaseItem: () => null, + }, + interrupts: { generic: { fallback: () => null } }, + }) + renderHtml(() => ) + renderHtml(() => ) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) +}) diff --git a/packages/ai-solid-ui/tsconfig.json b/packages/ai-solid-ui/tsconfig.json index 92effd4795..3cd8594f74 100644 --- a/packages/ai-solid-ui/tsconfig.json +++ b/packages/ai-solid-ui/tsconfig.json @@ -2,13 +2,11 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", "jsx": "preserve", "jsxImportSource": "solid-js", - "declarationMap": true, - "composite": true, - "lib": ["ES2022", "DOM"] + "lib": ["ES2022", "DOM"], + "rootDir": "../.." }, - "include": ["src"], + "include": ["src", "tests"], "exclude": ["node_modules", "dist"] } diff --git a/packages/ai-solid-ui/vite.config.ts b/packages/ai-solid-ui/vite.config.ts index 36849e034e..31293a7dbd 100644 --- a/packages/ai-solid-ui/vite.config.ts +++ b/packages/ai-solid-ui/vite.config.ts @@ -18,7 +18,7 @@ const config = defineConfig({ // vite-plugin-solid transforms them into the test graph, so tests run in // a DOM environment rather than 'node'. environment: 'happy-dom', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], diff --git a/packages/ai-svelte-ui/LICENSE b/packages/ai-svelte-ui/LICENSE new file mode 100644 index 0000000000..308cb68dc0 --- /dev/null +++ b/packages/ai-svelte-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ai-svelte-ui/README.md b/packages/ai-svelte-ui/README.md new file mode 100644 index 0000000000..26f3915c3b --- /dev/null +++ b/packages/ai-svelte-ui/README.md @@ -0,0 +1,5 @@ +# @tanstack/ai-svelte-ui + +Headless Svelte 5 chat UI. Call `createUI(chatOptions)` and supply every visible component. + +See https://tanstack.com/ai/latest/docs/ui/svelte diff --git a/packages/ai-svelte-ui/package.json b/packages/ai-svelte-ui/package.json new file mode 100644 index 0000000000..65726a9803 --- /dev/null +++ b/packages/ai-svelte-ui/package.json @@ -0,0 +1,70 @@ +{ + "name": "@tanstack/ai-svelte-ui", + "version": "0.1.0", + "description": "Headless Svelte 5 components for building TanStack AI chat interfaces with typed createUI adapters.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-svelte-ui" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "svelte": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "clean": "rm -rf ./dist", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "svelte-check", + "test:build": "publint --strict", + "build": "svelte-package -i src -o dist" + }, + "keywords": [ + "ai", + "tanstack", + "svelte", + "svelte5", + "chat", + "ui", + "headless" + ], + "dependencies": { + "@tanstack/ai-client": "workspace:^" + }, + "peerDependencies": { + "@tanstack/ai-svelte": "workspace:^", + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/package": "^2.3.10", + "@sveltejs/vite-plugin-svelte": "^7.3.0", + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "4.1.10", + "svelte": "^5.56.9", + "svelte-check": "^4.2.0", + "typescript": "5.9.3", + "vite": "^8.2.1" + } +} diff --git a/packages/ai-svelte-ui/src/create-ui.ts b/packages/ai-svelte-ui/src/create-ui.ts new file mode 100644 index 0000000000..bb74433c81 --- /dev/null +++ b/packages/ai-svelte-ui/src/create-ui.ts @@ -0,0 +1,214 @@ +import { getContext, setContext } from 'svelte' +import { + automaticPartsForMessage, + collectInlineToolNames, + resolveInterruptComponent, + selectChatUI, + selectMessageUI, +} from '@tanstack/ai-client/ui' +import type { + ChatUIData, + ChatUIInterrupt, + ChatUIPartKey, + ChatUIRegisteredInterruptId, + ChatUISelectedPart, + ChatUIToolName, + ChatUIToolsOf, + RegisteredUIInterrupt, +} from '@tanstack/ai-client/ui' +import type { + MessagePart, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai-client' + +// ponytail: duck-typed so createChat assigns; UIMessage generics are invariant +export type ChatUIHost = { + messages: ReadonlyArray + interrupts?: ReadonlyArray + error?: Error + isLoading?: boolean + status?: string + sendMessage?: (content: string, ...args: Array) => Promise | void +} + +type GenericInterruptComponents = { + [K in ChatUIRegisteredInterruptId as K extends 'fallback' + ? never + : K]?: unknown +} & { + fallback?: unknown +} + +export type ChatUIComponents = { + layout: unknown + message: unknown + input?: unknown + parts: { + [K in ChatUIPartKey]?: unknown + } & { + fallback?: unknown + } + tools?: { + [K in ChatUIToolName]?: unknown + } + interrupts?: { + tools?: { + [K in ChatUIToolName]?: + | unknown + | { component: unknown; placement?: 'inline' | 'list' } + } + generic?: GenericInterruptComponents + } +} + +export type UIDescriptor = { + key: symbol + warn: (key: string, message: string) => void + defineComponents: ( + components: ChatUIComponents, + ) => ChatUIComponents + useChat: () => ChatUIHost +} + +export type UIContextValue = { + chat: ChatUIHost + components: ChatUIComponents + ui: UIDescriptor +} + +function createWarnOnce() { + const seen = new Set() + return (key: string, message: string) => { + if (process.env.NODE_ENV === 'production') return + if (seen.has(key)) return + seen.add(key) + console.warn(message) + } +} + +export function createUI( + options: TOptions, +): UIDescriptor { + void options + const ui: UIDescriptor = { + key: Symbol('tanstack-ai-ui'), + warn: createWarnOnce(), + defineComponents(components) { + return components + }, + useChat() { + return getUIContext(ui).chat + }, + } + return ui +} + +export function setUIContext(value: UIContextValue) { + setContext(value.ui.key, value) +} + +export function getUIContext(ui: UIDescriptor) { + const value = getContext | undefined>(ui.key) + if (!value) { + throw new Error( + 'Chat UI components must be wrapped in UIProvider or UIChat.', + ) + } + return value +} + +export function readMessages(chat: ChatUIHost) { + return chat.messages ?? [] +} + +export function readInterrupts(chat: ChatUIHost) { + return chat.interrupts ?? [] +} + +export function inlineNames(components: ChatUIComponents) { + return collectInlineToolNames( + components.interrupts?.tools as Record | undefined, + ) +} + +export function messageParts( + ctx: UIContextValue, + message: UIMessage, +): Array { + return automaticPartsForMessage( + selectMessageUI(message, { + interrupts: readInterrupts(ctx.chat), + inlineToolNames: inlineNames(ctx.components), + }), + ) +} + +export function listInterrupts(ctx: UIContextValue) { + return selectChatUI({ + messages: readMessages(ctx.chat), + interrupts: readInterrupts(ctx.chat), + inlineToolNames: inlineNames(ctx.components), + }).interrupts +} + +export function toolComponent(ctx: UIContextValue, name: string) { + return ctx.components.tools?.[name as never] +} + +export function partComponent(ctx: UIContextValue, key: string) { + return ( + ctx.components.parts[key as ChatUIPartKey] ?? ctx.components.parts.fallback + ) +} + +export function interruptComponent( + ctx: UIContextValue, + interrupt: ChatUIInterrupt, +) { + return resolveInterruptComponent(interrupt, ctx.components.interrupts) +} + +export type LayoutProps = { + chat: ChatUIHost + readonly __ui?: TOptions +} +export type MessageProps = { + chat: ChatUIHost + message: UIMessage, ChatUIData> +} +export type InputProps = { + chat: ChatUIHost + readonly __ui?: TOptions +} +export type PartProps = { + chat: ChatUIHost + part: MessagePart, ChatUIData> +} +export type ToolProps< + TOptions, + TName extends ChatUIToolName = ChatUIToolName, +> = { + chat: ChatUIHost + part: Extract>, { name: TName }> + result?: ToolResultPart + interrupt?: Extract< + ChatUIInterrupt, + { kind: 'tool-approval'; toolName: TName } + > + readonly __ui?: TOptions +} +export type InterruptProps = { + chat: ChatUIHost + interrupt: ChatUIInterrupt + readonly __ui?: TOptions +} +export type RegisteredInterruptProps< + TOptions, + TId extends ChatUIRegisteredInterruptId = + ChatUIRegisteredInterruptId, +> = { + chat: ChatUIHost + interrupt: RegisteredUIInterrupt +} diff --git a/packages/ai-svelte-ui/src/index.ts b/packages/ai-svelte-ui/src/index.ts new file mode 100644 index 0000000000..b0139f8880 --- /dev/null +++ b/packages/ai-svelte-ui/src/index.ts @@ -0,0 +1,21 @@ +export { createUI, getUIContext } from './create-ui' +export type { + ChatUIComponents, + ChatUIHost, + InputProps, + InterruptProps, + LayoutProps, + MessageProps, + PartProps, + RegisteredInterruptProps, + ToolProps, + UIDescriptor, +} from './create-ui' + +export { default as UIChat } from './ui-chat.svelte' +export { default as UIProvider } from './ui-provider.svelte' +export { default as UIMessages } from './ui-messages.svelte' +export { default as UIMessage } from './ui-message.svelte' +export { default as UIPart } from './ui-part.svelte' +export { default as UIInterrupts } from './ui-interrupts.svelte' +export { default as UIInterrupt } from './ui-interrupt.svelte' diff --git a/packages/ai-svelte-ui/src/selected-part.svelte b/packages/ai-svelte-ui/src/selected-part.svelte new file mode 100644 index 0000000000..c4f4eb8182 --- /dev/null +++ b/packages/ai-svelte-ui/src/selected-part.svelte @@ -0,0 +1,61 @@ + + +{#if selected.key === 'toolCall'} + {@const Tool = toolComponent(ctx, selected.part.name) as any} + {#if Tool} + {@const Interrupt = ( + selected.interrupt + ? interruptComponent(ctx, selected.interrupt) + : undefined + ) as any} + + {#snippet renderInterrupt()} + {#if inline && selected.interrupt?.kind === 'tool-approval' && Interrupt && inlineNames(ctx.components).includes(selected.interrupt.toolName)} + + {/if} + {/snippet} + + {:else} + {ctx.ui.warn( + `tool:${selected.part.name}`, + `[tanstack-ai-ui] Missing tools.${selected.part.name} component`, + )} + {/if} +{:else} + {@const Part = partComponent(ctx, selected.key) as any} + {#if Part} + + {:else} + {ctx.ui.warn( + `part:${selected.key}`, + `[tanstack-ai-ui] Missing parts.${selected.key} component`, + )} + {/if} +{/if} diff --git a/packages/ai-svelte-ui/src/ui-chat.svelte b/packages/ai-svelte-ui/src/ui-chat.svelte new file mode 100644 index 0000000000..a3beb92838 --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-chat.svelte @@ -0,0 +1,35 @@ + + + + + {#snippet messages()} + + {/snippet} + {#snippet interrupts()} + + {/snippet} + {#snippet input()} + {#if Input} + + {/if} + {/snippet} + + diff --git a/packages/ai-svelte-ui/src/ui-interrupt.svelte b/packages/ai-svelte-ui/src/ui-interrupt.svelte new file mode 100644 index 0000000000..960fe585cc --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-interrupt.svelte @@ -0,0 +1,28 @@ + + +{#if Component} + +{:else} + {ctx.ui.warn( + `interrupt:${interrupt.id}`, + `[tanstack-ai-ui] Missing interrupt component for ${interrupt.kind}`, + )} +{/if} diff --git a/packages/ai-svelte-ui/src/ui-interrupts.svelte b/packages/ai-svelte-ui/src/ui-interrupts.svelte new file mode 100644 index 0000000000..e062a7cb1c --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-interrupts.svelte @@ -0,0 +1,24 @@ + + +{#if children} + {@render children(interrupts)} +{:else} + {#each interrupts as interrupt (interrupt.id)} + + {/each} +{/if} diff --git a/packages/ai-svelte-ui/src/ui-message.svelte b/packages/ai-svelte-ui/src/ui-message.svelte new file mode 100644 index 0000000000..ae6ac8d433 --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-message.svelte @@ -0,0 +1,31 @@ + + +{#if children} + {@render children(selectedParts)} +{:else} + + {#snippet parts()} + {#each selectedParts as part, index (`${message.id}-${index}`)} + + {/each} + {/snippet} + +{/if} diff --git a/packages/ai-svelte-ui/src/ui-messages.svelte b/packages/ai-svelte-ui/src/ui-messages.svelte new file mode 100644 index 0000000000..f3e53bdede --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-messages.svelte @@ -0,0 +1,25 @@ + + +{#if children} + {@render children(messages)} +{:else} + {#each messages as message (message.id)} + + {/each} +{/if} diff --git a/packages/ai-svelte-ui/src/ui-part.svelte b/packages/ai-svelte-ui/src/ui-part.svelte new file mode 100644 index 0000000000..a1ef07bfe1 --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-part.svelte @@ -0,0 +1,26 @@ + + +{#if selected} + +{/if} diff --git a/packages/ai-svelte-ui/src/ui-provider.svelte b/packages/ai-svelte-ui/src/ui-provider.svelte new file mode 100644 index 0000000000..3e81e18ec7 --- /dev/null +++ b/packages/ai-svelte-ui/src/ui-provider.svelte @@ -0,0 +1,23 @@ + + +{@render children?.()} diff --git a/packages/ai-svelte-ui/svelte.config.js b/packages/ai-svelte-ui/svelte.config.js new file mode 100644 index 0000000000..bfa43e59d7 --- /dev/null +++ b/packages/ai-svelte-ui/svelte.config.js @@ -0,0 +1,11 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import('@sveltejs/package').Config} */ +const config = { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +} + +export default config diff --git a/packages/ai-svelte-ui/tests/create-ui-types.test.ts b/packages/ai-svelte-ui/tests/create-ui-types.test.ts new file mode 100644 index 0000000000..8bf48919c9 --- /dev/null +++ b/packages/ai-svelte-ui/tests/create-ui-types.test.ts @@ -0,0 +1,20 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createUI } from '../src/create-ui' +import { chatOptions } from '../../ai-client/tests/ui-fixtures' + +describe('Svelte createUI types', () => { + it('infers defineComponents from a bare options variable', () => { + const ui = createUI(chatOptions) + ui.defineComponents({ + layout: {}, + message: {}, + parts: { fallback: {} }, + tools: { + getWeather: {}, + // @ts-expect-error This tool is not in chatOptions. + unknownTool: {}, + }, + }) + expectTypeOf(ui.defineComponents).toBeFunction() + }) +}) diff --git a/packages/ai-svelte-ui/tests/create-ui.test.ts b/packages/ai-svelte-ui/tests/create-ui.test.ts new file mode 100644 index 0000000000..5627d4ca15 --- /dev/null +++ b/packages/ai-svelte-ui/tests/create-ui.test.ts @@ -0,0 +1,11 @@ +import { render } from 'svelte/server' +import { describe, expect, it } from 'vitest' +import Automatic from './fixtures/automatic.svelte' +import Manual from './fixtures/manual.svelte' + +describe('Svelte createUI', () => { + it('renders automatic and snippet traversal', () => { + expect(render(Automatic).body).toContain('Paris') + expect(render(Manual).body).toContain('1') + }) +}) diff --git a/packages/ai-svelte-ui/tests/fixtures/automatic.svelte b/packages/ai-svelte-ui/tests/fixtures/automatic.svelte new file mode 100644 index 0000000000..bbf61eedd8 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/automatic.svelte @@ -0,0 +1,6 @@ + + + diff --git a/packages/ai-svelte-ui/tests/fixtures/empty.svelte b/packages/ai-svelte-ui/tests/fixtures/empty.svelte new file mode 100644 index 0000000000..0fbba99789 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/empty.svelte @@ -0,0 +1,2 @@ + diff --git a/packages/ai-svelte-ui/tests/fixtures/fallback.svelte b/packages/ai-svelte-ui/tests/fixtures/fallback.svelte new file mode 100644 index 0000000000..b8933a3bab --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/fallback.svelte @@ -0,0 +1,9 @@ + + +{part.type} diff --git a/packages/ai-svelte-ui/tests/fixtures/fixture-data.ts b/packages/ai-svelte-ui/tests/fixtures/fixture-data.ts new file mode 100644 index 0000000000..b89c2c69ad --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/fixture-data.ts @@ -0,0 +1,25 @@ +import { createUI } from '../../src/create-ui' +import { + chatOptions, + createSvelteChatResult, + messageWithToolResults, +} from '../../../ai-client/tests/ui-fixtures' +import Layout from './layout.svelte' +import Message from './message.svelte' +import Weather from './weather.svelte' +import Fallback from './fallback.svelte' +import Empty from './empty.svelte' + +export const ui = createUI(chatOptions) +export const chat: ReturnType = + createSvelteChatResult([messageWithToolResults]) +export const components = ui.defineComponents({ + layout: Layout, + message: Message, + parts: { fallback: Fallback }, + tools: { + getWeather: Weather, + purchaseItem: Empty, + }, + interrupts: { generic: { fallback: Empty } }, +}) diff --git a/packages/ai-svelte-ui/tests/fixtures/layout.svelte b/packages/ai-svelte-ui/tests/fixtures/layout.svelte new file mode 100644 index 0000000000..c12834c129 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/layout.svelte @@ -0,0 +1,9 @@ + + +{@render messages?.()} diff --git a/packages/ai-svelte-ui/tests/fixtures/manual.svelte b/packages/ai-svelte-ui/tests/fixtures/manual.svelte new file mode 100644 index 0000000000..1d01724890 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/manual.svelte @@ -0,0 +1,12 @@ + + + + + {#snippet children(messages)} + {messages.length} + {/snippet} + + diff --git a/packages/ai-svelte-ui/tests/fixtures/message.svelte b/packages/ai-svelte-ui/tests/fixtures/message.svelte new file mode 100644 index 0000000000..308f1005b6 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/message.svelte @@ -0,0 +1,9 @@ + + +
{@render parts?.()}
diff --git a/packages/ai-svelte-ui/tests/fixtures/weather.svelte b/packages/ai-svelte-ui/tests/fixtures/weather.svelte new file mode 100644 index 0000000000..be8834f402 --- /dev/null +++ b/packages/ai-svelte-ui/tests/fixtures/weather.svelte @@ -0,0 +1,9 @@ + + +{part.input?.city} diff --git a/packages/ai-svelte-ui/tsconfig.json b/packages/ai-svelte-ui/tsconfig.json new file mode 100644 index 0000000000..ad7559ac3d --- /dev/null +++ b/packages/ai-svelte-ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM"], + "rootDir": "../.." + }, + "include": ["src", "tests", "**/*.svelte"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-svelte-ui/vite.config.ts b/packages/ai-svelte-ui/vite.config.ts new file mode 100644 index 0000000000..6e8a02a816 --- /dev/null +++ b/packages/ai-svelte-ui/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [ + svelte({ + compilerOptions: { + runes: true, + }, + }), + ], + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: ['node_modules/', 'dist/', 'tests/'], + include: ['src/**/*.ts', 'src/**/*.svelte'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-vue-ui/package.json b/packages/ai-vue-ui/package.json index 64ea5e45d7..8b0d6e5064 100644 --- a/packages/ai-vue-ui/package.json +++ b/packages/ai-vue-ui/package.json @@ -54,6 +54,7 @@ ], "dependencies": { "@crazydos/vue-markdown": "^1.1.4", + "@tanstack/ai-client": "workspace:^", "@tanstack/ai-vue": "workspace:^", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", diff --git a/packages/ai-vue-ui/src/chat-input.vue b/packages/ai-vue-ui/src/chat-input.vue index 6462db48c4..76f82b7d33 100644 --- a/packages/ai-vue-ui/src/chat-input.vue +++ b/packages/ai-vue-ui/src/chat-input.vue @@ -1,4 +1,5 @@