- craete new nextjs project with tailwind as your CSS framework
npx create-next-app@latest- create openAI API Key at: https://platform.openai.com/
- carete an
.envfile at the root of your project with OpenAI key
OPENAI_API_KEY=***********- install vercel AI SDK
npm install ai @ai-sdk/openai zod- run your project
npm run dev- cleanup your components
- create an api route:
app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4-turbo'),
system: 'You are a helpful assistant.',
messages,
});
return result.toDataStreamResponse();
}- Rafctor the code on your page and use
useChathook
app/page.tsx
'use client';
import { useChat } from 'ai/react';
export default function Page() {
const { messages, input, handleInputChange, handleSubmit } = useChat({});
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input name="prompt" value={input} onChange={handleInputChange} />
<button type="submit">Submit</button>
</form>
</>
);
}- test your code: customize system message, style with tailwind (get help from LLM)
- use
isLoadingfromuseChatto handle loading state
function Page() {
const { isLoading} = useChat({});
return (
{isLoading && (
<div>
<Spinner />
<button type="button" onClick={() => stop()}>
Stop
</button>
</div>
)}
)
}- use
errorandreloadfromuseChatto handle error state
function Page() {
const { error, reload } = useChat({});
return (
{error && (
<>
<div>An error occurred.</div>
<button type="button" onClick={() => reload()}>
Retry
</button>
</>
)}
)
}- use 'stop' for caceling genration
function Page() {
const { stop } = useChat({});
return (
<button onClick={stop} disabled={!isLoading}>Stop</button>
)
}- create one or more custom tools of your choice.
- Think about the description
- you can communicate with external API
- as an example, here is a
whtehertool with hard codded data:
tools.ts
import { tool as createTool } from 'ai';
import { z } from 'zod';
export const weatherTool = createTool({
description: 'Display the weather for a location',
parameters: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async function ({ location }) {
await new Promise(resolve => setTimeout(resolve, 2000));
return { weather: 'Sunny', temperature: 75, location };
},
});
export const tools = {
displayWeather: weatherTool,
};- update the chat API route to include your tools
- add
maxStepsto enable
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { tools } from '@/ai/tools';
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a friendly assistant!',
messages,
maxSteps: 5,
tools,
});
return result.toDataStreamResponse();
}- create a UI component to render the result for your custom tool
- in the following example, we identify a tool call, and render the desired component
'use client';
import { useChat } from 'ai/react';
import { Weather } from '@/components/weather';
export default function Page() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role === 'user' ? 'User: ' : 'AI: '}</div>
<div>{message.content}</div>
<div>
{message.toolInvocations?.map(toolInvocation => {
const { toolName, toolCallId, state } = toolInvocation;
if (state === 'result') {
if (toolName === 'displayWeather') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
<Weather {...result} />
</div>
);
}
} else {
return (
<div key={toolCallId}>
{toolName === 'displayWeather' ? (
<div>Loading weather...</div>
) : null}
</div>
);
}
})}
</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}- The code flow is as follows:
- Check if the message has toolInvocations.
- Check if the tool invocation state is 'result'.
- If it's a result and the tool name is 'displayWeather', render the Weather component.
- If the tool invocation state is not 'result', show a loading message.
- add more tools and components for your choice
- this time we will use
streamUIfunction as anaction - start by creating mock function that communicate with the outside world
flight-actions.tsx
// simulate lataency in response
async function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
const searchFlights = async (
source: string,
destination: string,
date: string,
) => {
await wait(2000) // wait for 2 seconds
return [
{
id: '1',
flightNumber: 'AA123',
},
{
id: '2',
flightNumber: 'AA456',
},
];
};
const lookupFlight = async (flightNumber: string) => {
await wait(2000) // wait for 2 seconds
return {
flightNumber: flightNumber,
departureTime: '10:00 AM',
arrivalTime: '12:00 PM',
};
};- our custom tools will return
JSXinstead of plainobjects
flight-actions.tsx
export async function submitUserMessage(input: string) {
'use server';
const ui = await streamUI({
model: openai('gpt-4o'),
system: 'you are a flight booking assistant',
prompt: input,
text: async ({ content }) => <div>{content}</div>,
tools: {
searchFlights: {
description: 'search for flights',
parameters: z.object({
source: z.string().describe('The origin of the flight'),
destination: z.string().describe('The destination of the flight'),
date: z.string().describe('The date of the flight'),
}),
generate: async function* ({ source, destination, date }) {
yield `Searching for flights from ${source} to ${destination} on ${date}...`;
const results = await searchFlights(source, destination, date);
return (
<div>
{results.map(result => (
<div key={result.id}>
<div>{result.flightNumber}</div>
</div>
))}
</div>
);
},
},
lookupFlight: {
description: 'lookup details for a flight',
parameters: z.object({
flightNumber: z.string().describe('The flight number'),
}),
generate: async function* ({ flightNumber }) {
yield `Looking up details for flight ${flightNumber}...`;
const details = await lookupFlight(flightNumber);
return (
<div>
<div>Flight Number: {details.flightNumber}</div>
<div>Departure Time: {details.departureTime}</div>
<div>Arrival Time: {details.arrivalTime}</div>
</div>
);
},
},
},
});
return ui.value;
}
- use the
createAIfunction to create shard context
ai.ts
import { createAI } from 'ai/rsc';
import { submitUserMessage } from './actions';
export const AI = createAI<any[], React.ReactNode[]>({
initialUIState: [],
initialAIState: [],
actions: {
submitUserMessage,
},
});- wrap the entire application with this context
layout.tsx
import { type ReactNode } from 'react';
import { AI } from './ai';
export default function RootLayout({
children,
}: Readonly<{ children: ReactNode }>) {
return (
<AI>
<html lang="en">
<body>{children}</body>
</html>
</AI>
);
}- use the
useUIStateanduseActionshooks
page.tsx
'use client';
import { useState } from 'react';
import { AI } from './ai';
import { useActions, useUIState } from 'ai/rsc';
export default function Page() {
const [input, setInput] = useState<string>('');
const [conversation, setConversation] = useUIState<typeof AI>();
const { submitUserMessage } = useActions();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setInput('');
setConversation(currentConversation => [
...currentConversation,
<div>{input}</div>,
]);
const message = await submitUserMessage(input);
setConversation(currentConversation => [...currentConversation, message]);
};
return (
<div>
<div>
{conversation.map((message, i) => (
<div key={i}>{message}</div>
))}
</div>
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
/>
<button>Send Message</button>
</form>
</div>
</div>
);
}- to add interactivity we need to build custom react client components
- use
useUIStateto push new message back to the feed
Flights.tsx
'use client';
import { useActions, useUIState } from 'ai/rsc';
import { ReactNode } from 'react';
interface FlightsProps {
flights: { id: string; flightNumber: string }[];
}
export const Flights = ({ flights }: FlightsProps) => {
const { submitUserMessage } = useActions();
const [_, setMessages] = useUIState();
return (
<div>
{flights.map(result => (
<div key={result.id}>
<div
onClick={async () => {
const display = await submitUserMessage(
`lookupFlight ${result.flightNumber}`,
);
setMessages((messages: ReactNode[]) => [...messages, display]);
}}
>
{result.flightNumber}
</div>
</div>
))}
</div>
);
};- update the tool to return the custom interactive component
flight-actions.tsx
searchFlights: {
description: 'search for flights',
parameters: z.object({
source: z.string().describe('The origin of the flight'),
destination: z.string().describe('The destination of the flight'),
date: z.string().describe('The date of the flight'),
}),
generate: async function* ({ source, destination, date }) {
yield `Searching for flights from ${source} to ${destination} on ${date}...`;
const results = await searchFlights(source, destination, date);
return (<Flights flights={results} />);
},
}- build a skeleton component (get help from ChatGPT)
- use it to enhance UX
flight-actions.tsx
searchFlights: {
description: 'search for flights',
parameters: z.object({
source: z.string().describe('The origin of the flight'),
destination: z.string().describe('The destination of the flight'),
date: z.string().describe('The date of the flight'),
}),
generate: async function* ({ source, destination, date }) {
yield <FlightsLoader />
const results = await searchFlights(source, destination, date);
return <Flights flights={results} />;
},
}- add more components, fine tune system prompts and tool description
Thanks for this, I wonder how you handle tool call errors, let's say a tool throws an error, this would keep showing "Loading weather" (in the weather case), since there's no error state for tools. How would you handle this?