diff --git a/docs/config.json b/docs/config.json index 8860b6667..6f2c19b79 100644 --- a/docs/config.json +++ b/docs/config.json @@ -13,6 +13,10 @@ "label": "Overview", "to": "getting-started/overview" }, + { + "label": "Installation", + "to": "getting-started/installation" + }, { "label": "Quick Start", "to": "getting-started/quick-start" diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 000000000..8174132c7 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,132 @@ +--- +title: Installation +id: installation +order: 2 +--- + +Install TanStack AI along with a framework integration and an adapter for your preferred LLM provider. + +## Core + +Every project needs the core package: + +```bash +npm install @tanstack/ai +# or +pnpm add @tanstack/ai +# or +yarn add @tanstack/ai +``` + +## React + +```bash +npm install @tanstack/ai-react +``` + +The React integration provides the `useChat` hook for managing chat state. See the [@tanstack/ai-react API docs](../api/ai-react) for full details. + +```typescript +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + }); + // ... +} +``` + +## Solid + +```bash +npm install @tanstack/ai-solid +``` + +The Solid integration provides the `useChat` primitive for managing chat state. See the [@tanstack/ai-solid API docs](../api/ai-solid) for full details. + +```typescript +import { useChat, fetchServerSentEvents } from "@tanstack/ai-solid"; + +function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + }); + // ... +} +``` + +## Preact + +```bash +npm install @tanstack/ai-preact +``` + +The Preact integration provides the `useChat` hook for managing chat state. See the [@tanstack/ai-preact API docs](../api/ai-preact) for full details. + +```typescript +import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact"; + +function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + }); + // ... +} +``` + +## Vue + +```bash +npm install @tanstack/ai-vue +``` + +## Svelte + +```bash +npm install @tanstack/ai-svelte +``` + +## Headless (Framework-Agnostic) + +If you're using a framework without a dedicated integration, or building a custom solution, use the headless client directly: + +```bash +npm install @tanstack/ai-client +``` + +See the [@tanstack/ai-client API docs](../api/ai-client) for full details. + +## Adapters + +You also need an adapter for your LLM provider. Install one (or more) of the following: + +```bash +# OpenRouter (recommended — 300+ models with one API key) +npm install @tanstack/ai-openrouter + +# OpenAI +npm install @tanstack/ai-openai + +# Anthropic +npm install @tanstack/ai-anthropic + +# Google Gemini +npm install @tanstack/ai-gemini + +# Ollama (local models) +npm install @tanstack/ai-ollama + +# Groq +npm install @tanstack/ai-groq + +# Grok (xAI) +npm install @tanstack/ai-grok +``` + +See the [Adapters section](../adapters/openai) for provider-specific setup guides. + +## Next Steps + +- [Quick Start Guide](./quick-start) - Build a chat app in minutes +- [Tools Guide](../guides/tools) - Learn about the isomorphic tool system diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index b059a9c32..6f60a3736 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,7 +1,7 @@ --- title: Quick Start id: quick-start -order: 2 +order: 3 --- Get started with TanStack AI in minutes. This guide will walk you through creating a simple chat application using the React integration and OpenAI adapter. diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index 4f250edd3..922cdcf44 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -143,7 +143,8 @@ export type ToolCallPart = any> = export interface ToolResultPart { type: 'tool-result' toolCallId: string - content: string + /** Tool result content. String for text results, or an array for multimodal results. */ + content: string | Array state: ToolResultState error?: string // Error message if state is "error" } diff --git a/packages/typescript/ai-devtools/src/store/ai-context.tsx b/packages/typescript/ai-devtools/src/store/ai-context.tsx index 979cfb123..b1bc6f196 100644 --- a/packages/typescript/ai-devtools/src/store/ai-context.tsx +++ b/packages/typescript/ai-devtools/src/store/ai-context.tsx @@ -805,7 +805,9 @@ export const AIProvider: ParentComponent = (props) => { return { type: 'tool-result', toolCallId: part.toolCallId, - content: part.content, + content: Array.isArray(part.content) + ? JSON.stringify(part.content) + : part.content, state: part.state, error: part.error, } diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index 1cc8db37c..80c2f043a 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -195,7 +195,10 @@ export function devtoolsMiddleware(): ChatMiddleware { ...base, messageId: localMessageId || undefined, toolCallId: chunk.toolCallId, - result: chunk.result || '', + result: + typeof chunk.result === 'string' + ? chunk.result + : JSON.stringify(chunk.result ?? ''), timestamp: Date.now(), }) break diff --git a/packages/typescript/ai-event-client/src/index.ts b/packages/typescript/ai-event-client/src/index.ts index 98deb5285..a5da650f7 100644 --- a/packages/typescript/ai-event-client/src/index.ts +++ b/packages/typescript/ai-event-client/src/index.ts @@ -66,7 +66,7 @@ export interface ToolCallPart { export interface ToolResultPart { type: 'tool-result' toolCallId: string - content: string + content: string | Array state: ToolResultState error?: string } @@ -646,6 +646,20 @@ export interface AIDevtoolsEventMap { 'client:stopped': ClientStoppedEvent } +// Ensure a shared EventTarget exists on server environments (Node, Bun, +// Cloudflare Workers, etc.) so that emit() and on() operate on the same +// target. In browsers, `window` is used automatically. +// See https://github.com/TanStack/ai/issues/341 +if ( + typeof globalThis !== 'undefined' && + !globalThis.__TANSTACK_EVENT_TARGET__ && + typeof window === 'undefined' +) { + if (typeof EventTarget !== 'undefined') { + globalThis.__TANSTACK_EVENT_TARGET__ = new EventTarget() + } +} + class AiEventClient extends EventClient { constructor() { super({ diff --git a/packages/typescript/ai-gemini/src/adapters/image.ts b/packages/typescript/ai-gemini/src/adapters/image.ts index 2ccf47b58..bd928c01b 100644 --- a/packages/typescript/ai-gemini/src/adapters/image.ts +++ b/packages/typescript/ai-gemini/src/adapters/image.ts @@ -168,7 +168,13 @@ export class GeminiImageAdapter< id: generateId(this.name), model, images, - usage: undefined, + usage: response.usageMetadata + ? { + inputTokens: response.usageMetadata.promptTokenCount ?? 0, + outputTokens: response.usageMetadata.candidatesTokenCount ?? 0, + totalTokens: response.usageMetadata.totalTokenCount ?? 0, + } + : undefined, } } @@ -196,11 +202,26 @@ export class GeminiImageAdapter< }), ) + // GenerateImagesResponse may include usageMetadata in newer SDK versions + const usageMeta = (response as any).usageMetadata as + | { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + } + | undefined + return { id: generateId(this.name), model, images, - usage: undefined, + usage: usageMeta + ? { + inputTokens: usageMeta.promptTokenCount ?? 0, + outputTokens: usageMeta.candidatesTokenCount ?? 0, + totalTokens: usageMeta.totalTokenCount ?? 0, + } + : undefined, } } } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 1747ce4ec..2644bcbbd 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -706,11 +706,14 @@ export class OpenAITextAdapter< result.push({ type: 'function_call_output', call_id: message.toolCallId || '', + // Support multimodal tool outputs (OpenAI Responses API accepts + // string or array of content parts for function_call_output). output: - typeof message.content === 'string' + typeof message.content === 'string' || + Array.isArray(message.content) ? message.content : JSON.stringify(message.content), - }) + } as any) continue } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 387110b8b..07100111b 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -39,6 +39,25 @@ import type { Message, } from '@openrouter/sdk/models' +/** + * Convert snake_case keys to camelCase. + * The OpenRouter SDK's Zod transformer expects camelCase input and silently + * discards any snake_case fields. This helper normalises user-supplied + * modelOptions so that common snake_case variants (e.g. `tool_choice`) + * are accepted as well. + * See https://github.com/TanStack/ai/issues/314 + */ +function snakeToCamelKeys>( + obj: T, +): Record { + const result: Record = {} + for (const key of Object.keys(obj)) { + const camelKey = key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) + result[camelKey] = obj[key] + } + return result +} + export interface OpenRouterConfig extends SDKOptions {} export type OpenRouterTextModels = (typeof OPENROUTER_CHAT_MODELS)[number] @@ -521,15 +540,23 @@ export class OpenRouterTextAdapter< }) } + // Normalise snake_case keys to camelCase so the SDK's Zod transformer + // does not silently discard them (see #314). + const normalizedModelOptions = modelOptions + ? snakeToCamelKeys(modelOptions as Record) + : undefined + const request: ChatGenerationParams = { model: options.model + - (modelOptions?.variant ? `:${modelOptions.variant}` : ''), + (normalizedModelOptions?.variant + ? `:${normalizedModelOptions.variant}` + : ''), messages, temperature: options.temperature, maxTokens: options.maxTokens, topP: options.topP, - ...modelOptions, + ...normalizedModelOptions, tools: options.tools ? convertToolsToProviderFormat(options.tools) : undefined, diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 74c5d5ce5..2e329e215 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -1001,7 +1001,12 @@ class TextEngine< const chunks: Array = [] for (const result of results) { - const content = JSON.stringify(result.result) + // Preserve arrays (e.g. multimodal content parts) and strings; + // stringify other values. + const content = + typeof result.result === 'string' || Array.isArray(result.result) + ? result.result + : JSON.stringify(result.result) chunks.push({ type: 'TOOL_CALL_END', diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts index b7f97b880..b8244271d 100644 --- a/packages/typescript/ai/src/activities/chat/messages.ts +++ b/packages/typescript/ai/src/activities/chat/messages.ts @@ -248,7 +248,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { if (part.output !== undefined && !emittedToolResultIds.has(part.id)) { messageList.push({ role: 'tool', - content: JSON.stringify(part.output), + content: Array.isArray(part.output) + ? part.output + : JSON.stringify(part.output), toolCallId: part.id, }) emittedToolResultIds.add(part.id) diff --git a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts index 80b94d59a..be7b2833d 100644 --- a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts @@ -97,7 +97,7 @@ export function updateToolResultPart( messages: Array, messageId: string, toolCallId: string, - content: string, + content: string | Array, state: ToolResultState, error?: string, ): Array { diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index dc215366b..2ac59582e 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -288,7 +288,11 @@ export class StreamProcessor { ) // Step 2: Create a tool-result part (for LLM conversation history) - const content = typeof output === 'string' ? output : JSON.stringify(output) + // Preserve arrays (e.g. multimodal content parts) as-is. + const content = + typeof output === 'string' || Array.isArray(output) + ? output + : JSON.stringify(output) const toolResultState: ToolResultState = error ? 'error' : 'complete' updatedMessages = updateToolResultPart( @@ -944,10 +948,14 @@ export class StreamProcessor { // Step 1: Update the tool-call part's output field (for UI consistency // with client tools — see GitHub issue #176) let output: unknown - try { - output = JSON.parse(chunk.result) - } catch { + if (Array.isArray(chunk.result)) { output = chunk.result + } else { + try { + output = JSON.parse(chunk.result) + } catch { + output = chunk.result + } } this.messages = updateToolCallWithOutput( this.messages, diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts index f6fe2060e..e14fd1d0f 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts @@ -161,7 +161,7 @@ export class ToolCallManager { for (const toolCall of toolCallsArray) { const tool = this.tools.find((t) => t.name === toolCall.function.name) - let toolResultContent: string + let toolResultContent: string | Array if (tool?.execute) { try { // Parse arguments (normalize "null" to "{}" for empty tool_use blocks) @@ -213,8 +213,12 @@ export class ToolCallManager { } } + // Preserve arrays (e.g. multimodal content parts) as-is; + // stringify other non-string values for the conversation history. toolResultContent = - typeof result === 'string' ? result : JSON.stringify(result) + typeof result === 'string' || Array.isArray(result) + ? result + : JSON.stringify(result) } catch (error: unknown) { // If tool execution fails, add error message const message = diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index ca195bc4a..1b7ace0e1 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -298,7 +298,8 @@ export interface ToolCallPart { export interface ToolResultPart { type: 'tool-result' toolCallId: string - content: string + /** Tool result content. String for text results, or an array for multimodal results (e.g. images). */ + content: string | Array state: ToolResultState error?: string // Error message if state is "error" } @@ -872,8 +873,8 @@ export interface ToolCallEndEvent extends BaseAGUIEvent { toolName: string /** Final parsed input arguments */ input?: unknown - /** Tool execution result (if executed) */ - result?: string + /** Tool execution result (if executed). String for text, array for multimodal content. */ + result?: string | Array } /**