Skip to content

Commit 028f42a

Browse files
committed
feat: tanstack-ai for react
1 parent 7b7d644 commit 028f42a

File tree

6 files changed

+197
-171
lines changed

6 files changed

+197
-171
lines changed

frameworks/react-cra/examples/tanchat/assets/src/components/example-AIAssistant.tsx

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import { Store } from '@tanstack/store'
55
import { Send, X, ChevronRight } from 'lucide-react'
66
import { Streamdown } from 'streamdown'
77

8-
import { useChat } from '@ai-sdk/react'
9-
import { DefaultChatTransport } from 'ai'
8+
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
9+
import { clientTools } from '@tanstack/ai-client'
10+
import type { UIMessage } from '@tanstack/ai-react'
1011

1112
import GuitarRecommendation from './example-GuitarRecommendation'
13+
import { recommendGuitarToolDef } from '@/lib/example.guitar-tools'
1214

13-
import type { UIMessage } from 'ai'
15+
const recommendGuitarToolClient = recommendGuitarToolDef.client(({ id }) => ({
16+
id: +id,
17+
}))
18+
19+
const tools = clientTools(recommendGuitarToolClient)
1420

1521
export const showAIAssistant = new Store(false)
1622

@@ -43,10 +49,10 @@ function Messages({ messages }: { messages: Array<UIMessage> }) {
4349
: 'bg-transparent'
4450
}`}
4551
>
46-
{parts.map((part) => {
47-
if (part.type === 'text') {
52+
{parts.map((part, index) => {
53+
if (part.type === 'text' && part.content) {
4854
return (
49-
<div className="flex items-start gap-2 px-4">
55+
<div key={index} className="flex items-start gap-2 px-4">
5056
{role === 'assistant' ? (
5157
<div className="w-6 h-6 rounded-lg bg-gradient-to-r from-orange-500 to-red-600 flex items-center justify-center text-xs font-medium text-white flex-shrink-0">
5258
AI
@@ -57,21 +63,19 @@ function Messages({ messages }: { messages: Array<UIMessage> }) {
5763
</div>
5864
)}
5965
<div className="flex-1 min-w-0 text-white prose dark:prose-invert max-w-none prose-sm">
60-
<Streamdown>{part.text}</Streamdown>
66+
<Streamdown>{part.content}</Streamdown>
6167
</div>
6268
</div>
6369
)
6470
}
6571
if (
66-
part.type === 'tool-recommendGuitar' &&
67-
part.state === 'output-available' &&
68-
(part.output as { id: string })?.id
72+
part.type === 'tool-call' &&
73+
part.name === 'recommendGuitar' &&
74+
part.output
6975
) {
7076
return (
71-
<div key={id} className="max-w-[80%] mx-auto">
72-
<GuitarRecommendation
73-
id={(part.output as { id: string })?.id}
74-
/>
77+
<div key={part.id} className="max-w-[80%] mx-auto">
78+
<GuitarRecommendation id={String(part.output?.id)} />
7579
</div>
7680
)
7781
}
@@ -85,9 +89,8 @@ function Messages({ messages }: { messages: Array<UIMessage> }) {
8589
export default function AIAssistant() {
8690
const isOpen = useStore(showAIAssistant)
8791
const { messages, sendMessage } = useChat({
88-
transport: new DefaultChatTransport({
89-
api: '/demo/api/tanchat',
90-
}),
92+
connection: fetchServerSentEvents('/demo/api/tanchat'),
93+
tools,
9194
})
9295
const [input, setInput] = useState('')
9396

@@ -124,8 +127,10 @@ export default function AIAssistant() {
124127
<form
125128
onSubmit={(e) => {
126129
e.preventDefault()
127-
sendMessage({ text: input })
128-
setInput('')
130+
if (input.trim()) {
131+
sendMessage(input)
132+
setInput('')
133+
}
129134
}}
130135
>
131136
<div className="relative">
@@ -143,9 +148,9 @@ export default function AIAssistant() {
143148
Math.min(target.scrollHeight, 120) + 'px'
144149
}}
145150
onKeyDown={(e) => {
146-
if (e.key === 'Enter' && !e.shiftKey) {
151+
if (e.key === 'Enter' && !e.shiftKey && input.trim()) {
147152
e.preventDefault()
148-
sendMessage({ text: input })
153+
sendMessage(input)
149154
setInput('')
150155
}
151156
}}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { toolDefinition } from '@tanstack/ai'
2+
import { z } from 'zod'
3+
import guitars from '@/data/example-guitars'
4+
5+
// Tool definition for getting guitars
6+
export const getGuitarsToolDef = toolDefinition({
7+
name: 'getGuitars',
8+
description: 'Get all products from the database',
9+
inputSchema: z.object({}),
10+
outputSchema: z.array(
11+
z.object({
12+
id: z.number(),
13+
name: z.string(),
14+
image: z.string(),
15+
description: z.string(),
16+
shortDescription: z.string(),
17+
price: z.number(),
18+
}),
19+
),
20+
})
21+
22+
// Server implementation
23+
export const getGuitars = getGuitarsToolDef.server(() => guitars)
24+
25+
// Tool definition for guitar recommendation
26+
export const recommendGuitarToolDef = toolDefinition({
27+
name: 'recommendGuitar',
28+
description:
29+
'REQUIRED tool to display a guitar recommendation to the user. This tool MUST be used whenever recommending a guitar - do NOT write recommendations yourself. This displays the guitar in a special appealing format with a buy button.',
30+
inputSchema: z.object({
31+
id: z
32+
.union([z.string(), z.number()])
33+
.describe(
34+
'The ID of the guitar to recommend (from the getGuitars results)',
35+
),
36+
}),
37+
outputSchema: z.object({
38+
id: z.number(),
39+
}),
40+
})

frameworks/react-cra/examples/tanchat/assets/src/routes/demo/api.tanchat.ts.ejs

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,63 @@
11
import { createFileRoute } from '@tanstack/react-router'
2-
<% if (addOnEnabled['netlify']) { %>import { createAnthropic } from '@ai-sdk/anthropic'
3-
<% } else { %>import { anthropic } from '@ai-sdk/anthropic'
4-
<% } %>import { convertToModelMessages, stepCountIs, streamText } from 'ai'
2+
import { chat, maxIterations, toStreamResponse } from '@tanstack/ai'
3+
import { anthropic } from '@tanstack/ai-anthropic'
54

6-
import getTools from '@/utils/demo.tools'
5+
import { getGuitars, recommendGuitarToolDef } from '@/lib/example.guitar-tools'
76

87
const SYSTEM_PROMPT = `You are a helpful assistant for a store that sells guitars.
98

10-
You can use the following tools to help the user:
9+
CRITICAL INSTRUCTIONS - YOU MUST FOLLOW THIS EXACT WORKFLOW:
1110

12-
- getGuitars: Get all guitars from the database
13-
- recommendGuitar: Recommend a guitar to the user
11+
When a user asks for a guitar recommendation:
12+
1. FIRST: Use the getGuitars tool (no parameters needed)
13+
2. SECOND: Use the recommendGuitar tool with the ID of the guitar you want to recommend
14+
3. NEVER write a recommendation directly - ALWAYS use the recommendGuitar tool
15+
16+
IMPORTANT:
17+
- The recommendGuitar tool will display the guitar in a special, appealing format
18+
- You MUST use recommendGuitar for ANY guitar recommendation
19+
- ONLY recommend guitars from our inventory (use getGuitars first)
20+
- The recommendGuitar tool has a buy button - this is how customers purchase
21+
- Do NOT describe the guitar yourself - let the recommendGuitar tool do it
1422
`
15-
<% if (addOnEnabled['netlify']) { %>const anthropic = createAnthropic({
16-
baseURL: process.env.ANTHROPIC_BASE_URL
17-
? `${process.env.ANTHROPIC_BASE_URL}/v1`
18-
: undefined,
19-
apiKey: process.env.ANTHROPIC_API_KEY,
20-
headers: {
21-
'user-agent': 'anthropic/',
22-
},
23-
})
24-
<% } %>
25-
export const Route = createFileRoute('/api/demo-chat')({
23+
24+
export const Route = createFileRoute('/demo/api/tanchat')({
2625
server: {
2726
handlers: {
2827
POST: async ({ request }) => {
28+
// Capture request signal before reading body (it may be aborted after body is consumed)
29+
const requestSignal = request.signal
30+
31+
// If request is already aborted, return early
32+
if (requestSignal.aborted) {
33+
return new Response(null, { status: 499 }) // 499 = Client Closed Request
34+
}
35+
36+
const abortController = new AbortController()
37+
2938
try {
3039
const { messages } = await request.json()
3140

32-
const tools = await getTools()
33-
34-
const result = await streamText({
35-
model: anthropic('<% if (addOnEnabled['netlify']) { %>claude-sonnet-4-5-20250929<% } else { %>claude-haiku-4-5<% } %>'),
36-
messages: convertToModelMessages(messages),
37-
temperature: 0.7,
38-
stopWhen: stepCountIs(5),
39-
system: SYSTEM_PROMPT,
40-
tools,
41+
const stream = chat({
42+
adapter: anthropic(),
43+
model: 'claude-haiku-4-5',
44+
tools: [
45+
getGuitars, // Server tool
46+
recommendGuitarToolDef, // No server execute - client will handle
47+
],
48+
systemPrompts: [SYSTEM_PROMPT],
49+
agentLoopStrategy: maxIterations(5),
50+
messages,
51+
abortController,
4152
})
4253

43-
return result.toUIMessageStreamResponse()
44-
} catch (error) {
54+
return toStreamResponse(stream, { abortController })
55+
} catch (error: any) {
4556
console.error('Chat API error:', error)
57+
// If request was aborted, return early (don't send error response)
58+
if (error.name === 'AbortError' || abortController.signal.aborted) {
59+
return new Response(null, { status: 499 }) // 499 = Client Closed Request
60+
}
4661
return new Response(
4762
JSON.stringify({ error: 'Failed to process chat request' }),
4863
{

0 commit comments

Comments
 (0)