UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

595 lines (476 loc) 16.8 kB
--- name: ai-core/chat-experience description: > End-to-end chat implementation: server endpoint with chat() and toServerSentEventsResponse(), client-side useChat hook with fetchServerSentEvents(), message rendering with UIMessage parts, multimodal content, thinking/reasoning display. Covers streaming states, connection adapters, and message format conversions. NOT Vercel AI SDK — uses chat() not streamText(). type: sub-skill library: tanstack-ai library_version: '0.10.0' sources: - 'TanStack/ai:docs/getting-started/quick-start.md' - 'TanStack/ai:docs/chat/streaming.md' - 'TanStack/ai:docs/chat/connection-adapters.md' - 'TanStack/ai:docs/chat/thinking-content.md' - 'TanStack/ai:docs/advanced/multimodal-content.md' --- # Chat Experience This skill builds on ai-core. Read it first for critical rules. ## Setup — Minimal Chat App ### Server: API Route (TanStack Start) ```typescript // src/routes/api.chat.ts import { createFileRoute } from '@tanstack/react-router' import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' export const Route = createFileRoute('/api/chat')({ server: { handlers: { POST: async ({ request }) => { const abortController = new AbortController() const body = await request.json() const { messages } = body const stream = chat({ adapter: openaiText('gpt-5.2'), messages, systemPrompts: ['You are a helpful assistant.'], abortController, }) return toServerSentEventsResponse(stream, { abortController }) }, }, }, }) ``` ### Client: React Component ```typescript // src/routes/index.tsx import { useState } from 'react' import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' import type { UIMessage } from '@tanstack/ai-react' function ChatPage() { const [input, setInput] = useState('') const { messages, sendMessage, isLoading, error, stop } = useChat({ connection: fetchServerSentEvents('/api/chat'), }) const handleSubmit = () => { if (!input.trim()) return sendMessage(input.trim()) setInput('') } return ( <div> <div> {messages.map((message: UIMessage) => ( <div key={message.id}> <strong>{message.role}:</strong> {message.parts.map((part, i) => { if (part.type === 'text') { return <p key={i}>{part.content}</p> } return null })} </div> ))} </div> {error && <div>Error: {error.message}</div>} <div> <input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSubmit() } }} disabled={isLoading} placeholder="Type a message..." /> {isLoading ? ( <button onClick={stop}>Stop</button> ) : ( <button onClick={handleSubmit} disabled={!input.trim()}> Send </button> )} </div> </div> ) } ``` Vue/Solid/Svelte/Preact have identical patterns with different hook imports (e.g., `import { useChat } from '@tanstack/ai-solid'`). ## Core Patterns ### 1. Streaming Chat with SSE Server returns a streaming SSE Response; client parses it automatically. **Server:** ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' const stream = chat({ adapter: anthropicText('claude-sonnet-4-5'), messages, modelOptions: { temperature: 0.7, max_tokens: 2000, // Anthropic-native key }, systemPrompts: ['You are a helpful assistant.'], abortController, }) return toServerSentEventsResponse(stream, { abortController }) ``` **Client:** ```typescript import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' const { messages, sendMessage, isLoading, error, stop, status } = useChat({ connection: fetchServerSentEvents('/api/chat'), body: { provider: 'anthropic', model: 'claude-sonnet-4-5' }, onFinish: (message) => { console.log('Response complete:', message.id) }, onError: (err) => { console.error('Stream error:', err) }, }) ``` The `body` field is merged into the POST request body alongside `messages`, letting the server read `data.provider`, `data.model`, etc. The `status` field tracks the chat lifecycle: `'ready'` | `'submitted'` | `'streaming'` | `'error'`. ### 2. Rendering Thinking/Reasoning Content Models with extended thinking (Claude, Gemini) emit `ThinkingPart` in the message parts array. ```typescript import type { UIMessage } from '@tanstack/ai-react' function MessageRenderer({ message }: { message: UIMessage }) { return ( <div> {message.parts.map((part, i) => { if (part.type === 'thinking') { const isComplete = message.parts .slice(i + 1) .some((p) => p.type === 'text') return ( <details key={i} open={!isComplete}> <summary>{isComplete ? 'Thought process' : 'Thinking...'}</summary> <pre>{part.content}</pre> </details> ) } if (part.type === 'text' && part.content) { return <p key={i}>{part.content}</p> } if (part.type === 'tool-call') { return ( <div key={part.id}> Tool call: {part.name} ({part.state}) </div> ) } return null })} </div> ) } ``` Server-side, enable thinking via `modelOptions` on the adapter: ```typescript import { geminiText } from '@tanstack/ai-gemini' const stream = chat({ adapter: geminiText('gemini-2.5-flash'), messages, modelOptions: { thinkingConfig: { includeThoughts: true, thinkingBudget: 100, }, }, }) ``` ### 3. Sending Multimodal Content (Images) Use `sendMessage` with a `MultimodalContent` object instead of a plain string. ```typescript import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' import type { ContentPart } from '@tanstack/ai' const { sendMessage } = useChat({ connection: fetchServerSentEvents('/api/chat'), }) function sendImageMessage(text: string, imageBase64: string, mimeType: string) { const contentParts: Array<ContentPart> = [ { type: 'text', content: text }, { type: 'image', source: { type: 'data', value: imageBase64, mimeType }, }, ] sendMessage({ content: contentParts }) } function sendImageUrl(text: string, imageUrl: string) { const contentParts: Array<ContentPart> = [ { type: 'text', content: text }, { type: 'image', source: { type: 'url', value: imageUrl }, }, ] sendMessage({ content: contentParts }) } ``` Render image parts in received messages: ```typescript if (part.type === 'image') { const src = part.source.type === 'url' ? part.source.value : `data:${part.source.mimeType};base64,${part.source.value}` return <img key={i} src={src} alt="Attached image" /> } ``` ### 4. HTTP Stream Format (Alternative to SSE) Use `toHttpResponse` + `fetchHttpStream` for newline-delimited JSON instead of SSE. **Server:** ```typescript import { chat, toHttpResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ adapter: openaiText('gpt-5.2'), messages, abortController, }) return toHttpResponse(stream, { abortController }) ``` **Client:** ```typescript import { useChat, fetchHttpStream } from '@tanstack/ai-react' const { messages, sendMessage } = useChat({ connection: fetchHttpStream('/api/chat'), }) ``` The only difference is swapping `toServerSentEventsResponse` / `fetchServerSentEvents` for `toHttpResponse` / `fetchHttpStream`. Everything else stays identical. ### 5. MCP Tool Discovery via `chat({ mcp })` Pass `mcp` to let `chat()` own discovery **and** lifecycle for one or more MCP clients. Useful when you want minimal boilerplate and don't need to reuse the clients across calls. ```typescript // Prop shape: // chat({ // ..., // mcp: { // clients: Array<MCPClient | MCPClients>, // connection?: 'close' | 'keep-alive', // default: 'close' // lazyTools?: boolean, // onDiscoveryError?: (error: unknown, source) => void, // } // }) ``` - **`clients`** — one or more `MCPClient` / `MCPClients` instances. - **`connection`** — `'close'` (default) closes each client when the run ends (after the agent loop completes and the stream is drained); with `'keep-alive'`, `chat()` never closes the clients — the caller owns their lifecycle (keep connections warm across requests). - **`lazyTools`** — forwarded to `tools({ lazy: true })` so tool schemas are sent to the LLM on demand. - **`onDiscoveryError`** — throw (or re-throw) to fail the entire call fast; return normally to skip that source and continue. Omit to rethrow (fail-fast). **When to use `mcp` vs. the tools spread:** | Approach | Use when | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `chat({ mcp: { clients: [...] } })` | You want discovery + lifecycle managed for you, and don't need fully-typed input/output schemas | | `tools: [...await client.tools([toolDefinition(...)])]` | You want fully-typed MCP tools with Zod input/output validation | **Server-side example:** ```typescript import { createFileRoute } from '@tanstack/react-router' import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' export const Route = createFileRoute('/api/chat')({ server: { handlers: { POST: async ({ request }) => { const { messages } = await request.json() const mcpClient = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) const stream = chat({ adapter: openaiText('gpt-5.5'), messages, mcp: { clients: [mcpClient], connection: 'keep-alive', // chat() won't close it — reuse across requests }, }) return toServerSentEventsResponse(stream) // connection: 'keep-alive' — chat() never closes mcpClient; it stays open for reuse across runs. }, }, }, }) ``` ## Common Mistakes ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText) ```typescript // WRONG import { streamText } from 'ai' import { openai } from '@ai-sdk/openai' const result = streamText({ model: openai('gpt-4o'), messages }) // CORRECT import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ adapter: openaiText('gpt-5.2'), messages }) ``` ### b. CRITICAL: Using Vercel createOpenAI() provider pattern ```typescript // WRONG import { createOpenAI } from '@ai-sdk/openai' const openai = createOpenAI({ apiKey }) streamText({ model: openai('gpt-4o'), messages }) // CORRECT import { openaiText } from '@tanstack/ai-openai' import { chat } from '@tanstack/ai' chat({ adapter: openaiText('gpt-5.2'), messages }) ``` ### c. CRITICAL: Using monolithic openai() instead of openaiText() ```typescript // WRONG import { openai } from '@tanstack/ai-openai' chat({ adapter: openai(), model: 'gpt-5.2', messages }) // CORRECT import { openaiText } from '@tanstack/ai-openai' chat({ adapter: openaiText('gpt-5.2'), messages }) ``` The monolithic `openai()` adapter is deprecated. Use tree-shakeable adapters: `openaiText()`, `openaiImage()`, `openaiSpeech()`, etc. ### d. HIGH: Using toResponseStream instead of toServerSentEventsResponse ```typescript // WRONG import { toResponseStream } from '@tanstack/ai' return toResponseStream(stream, { abortController }) // CORRECT import { toServerSentEventsResponse } from '@tanstack/ai' return toServerSentEventsResponse(stream, { abortController }) ``` ### e. HIGH: Passing model as separate parameter to chat() ```typescript // WRONG chat({ adapter: openaiText(), model: 'gpt-5.2', messages }) // CORRECT chat({ adapter: openaiText('gpt-5.2'), messages }) ``` The model is passed to the adapter factory, not to `chat()`. ### f. HIGH: Passing sampling options at the root of chat() Sampling options (`temperature`, token limits, `top_p`/`topP`) are **not** top-level fields on `chat()`. They live inside `modelOptions` using the provider's native key. ```typescript // WRONG — temperature/maxTokens are not root options chat({ adapter, messages, temperature: 0.7, maxTokens: 1000 }) // WRONG — there is no `options` field either chat({ adapter, messages, options: { temperature: 0.7, maxTokens: 1000 } }) // CORRECT — inside modelOptions, provider-native keys (OpenAI shown) chat({ adapter, messages, modelOptions: { temperature: 0.7, max_output_tokens: 1000 }, }) ``` `temperature` is universal across providers; token limits use provider-native keys (`max_output_tokens` for OpenAI, `max_tokens` for Anthropic/Grok, `maxOutputTokens` for Gemini, `max_completion_tokens` for Groq, `maxCompletionTokens` for OpenRouter, and `num_predict` nested under `modelOptions.options` for Ollama). See ai-core/adapter-configuration/SKILL.md. ### g. HIGH: Using providerOptions instead of modelOptions ```typescript // WRONG chat({ adapter, messages, providerOptions: { responseFormat: { type: 'json_object' } }, }) // CORRECT chat({ adapter, messages, modelOptions: { responseFormat: { type: 'json_object' } }, }) ``` ### h. HIGH: Implementing custom SSE stream instead of using toServerSentEventsResponse ```typescript // WRONG const readable = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() for await (const chunk of stream) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) } controller.enqueue(encoder.encode('data: [DONE]\n\n')) controller.close() }, }) return new Response(readable, { headers: { 'Content-Type': 'text/event-stream' }, }) // CORRECT import { toServerSentEventsResponse } from '@tanstack/ai' return toServerSentEventsResponse(stream, { abortController }) ``` `toServerSentEventsResponse` handles SSE formatting, abort signals, error events (RUN_ERROR), and correct headers automatically. ### i. HIGH: Implementing custom onEnd/onFinish callbacks instead of middleware ```typescript // WRONG chat({ adapter, messages, onEnd: (result) => { trackAnalytics(result) }, }) // CORRECT import type { ChatMiddleware } from '@tanstack/ai' const analytics: ChatMiddleware = { name: 'analytics', onFinish(ctx, info) { trackAnalytics({ reason: info.finishReason, iterations: ctx.iteration }) }, onUsage(ctx, usage) { trackTokens(usage.totalTokens) }, } chat({ adapter, messages, middleware: [analytics] }) ``` `chat()` has no `onEnd`/`onFinish` option. Use `middleware` for lifecycle events. See also: ai-core/middleware/SKILL.md. ### j. HIGH: Importing from @tanstack/ai-client instead of framework package ```typescript // WRONG import { fetchServerSentEvents } from '@tanstack/ai-client' import { useChat } from '@tanstack/ai-react' // CORRECT import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' ``` Framework packages re-export everything needed from `@tanstack/ai-client`. Import from `@tanstack/ai-client` only in vanilla JS (no framework). ### k. MEDIUM: Not handling RUN_ERROR events in streaming context Streaming errors arrive as `RUN_ERROR` events in the stream, not as thrown exceptions. The `useChat` hook surfaces these via the `error` state and `onError` callback. If you consume the stream manually (without `useChat`), check for `RUN_ERROR` chunks: ```typescript for await (const chunk of stream) { if (chunk.type === 'RUN_ERROR') { console.error('Stream error:', chunk.error.message) break } if (chunk.type === 'TEXT_MESSAGE_CONTENT') { process.stdout.write(chunk.delta) } } ``` If not handled, the UI appears to hang with no feedback. ## Cross-References - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features - See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events