概述
本篇為系列文章的第三部分,也是最後一個部分。請讀者先看過前面兩個部分再閱讀本篇教學。前面內容可以點擊下面連結
本篇教學會說明如何透過 Vercel AI SDK 進行 Chatbot 的實作。
建構 chatbot
在 api/chat/route.ts 中定義一個 streamText 的路由處理器。如果部署在 Vercel 上,請記得將最大持續時間設置為大於 10 秒的值。
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {}從 request body 中獲取傳入的訊息。
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
}呼叫 streamText,並傳入你的模型和訊息。
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-4o"),
messages,
});
}將生成的結果作為流式響應返回。
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-4o"),
messages,
});
return result.toDataStreamResponse();
}在你的 chat.tsx 檔案中,從 ai/react 匯入 useChat 的 hook。
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const {} = useChat();
return <div>Chatbot</div>;
}解構 messages 並遍歷它們來顯示聊天訊息。
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages } = useChat();
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.content}
</div>
))}
</div>
);
}解構來自 useChat 的 hook,得到 input、handleInputChange 和 handleSubmit。並新增一個輸入欄位和一個表單來提交訊息。
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}運行應用程式並到 /chat 頁面以查看聊天機器人的運行狀況。
bun run dev回到你的 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-4o"),
system:
"You are an unhelpful assistant that only responds to users with confusing riddles.",
messages,
});
return result.toDataStreamResponse();
}回到瀏覽器並提出一個新的問題,以查看新的回應。注意,我們完全改變了模型的行為,而不需要改變模型本身。
最後一步!將系統提示更改為你喜歡的任何內容。
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-4o"),
system: `You are Steve Jobs. Assume his character, both strengths and flaws.
Respond exactly how he would, in exactly his tone.
It is 1984 you have just created the Macintosh.`,
messages,
});
return result.toDataStreamResponse();
}現在提問類似這樣的問題:
What do you think of Bill?注意,回應聽起來非常像史蒂夫·賈伯斯可能會說的話。這就是系統提示的威力。
最後,試著詢問關於舊金山現在的天氣。
What's the weather like in San Francisco?注意到它無法回應,我們可以使用 tool 來解決這個問題。
新增 tool
回到你的 route handler 並定義你的第一個工具:getWeather。這個工具將用來獲取某個地點的當前天氣。我們將使用 AI SDK 中的 tool 幫助函數來定義這個工具。
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({}),
},
});
return result.toDataStreamResponse();
}import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({}),
},
});
return result.toDataStreamResponse();
}首先,你需要為你的工具添加描述。這是模型用來決定何時使用該工具的依據。
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
}),
},
});
return result.toDataStreamResponse();
}import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
}),
},
});
return result.toDataStreamResponse();
}現在,我們需要定義工具運行所需的參數。我們將使用 Zod 來定義這些參數的 schema。
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
city: z.string(),
}),
}),
},
});
return result.toDataStreamResponse();
}import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
city: z.string(),
}),
}),
},
});
return result.toDataStreamResponse();
}我們可以利用模型的生成能力來定義可以從對話中推斷出來的參數。在這個情況下,我們需要該地點的緯度、經度和城市名稱。我們預期使用者會提供城市名稱,而模型則可以根據這個城市名稱生成緯度和經度。
最後,我們定義一個執行函數。這段代碼會在工具被呼叫時執行。
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
city: z.string(),
}),
execute: async ({ latitude, longitude, city }) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weathercode,relativehumidity_2m&timezone=auto`,
);
const weatherData = await response.json();
return {
temperature: weatherData.current.temperature_2m,
weatherCode: weatherData.current.weathercode,
humidity: weatherData.current.relativehumidity_2m,
city,
};
},
}),
},
});
return result.toDataStreamResponse();
}import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather at a location",
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
city: z.string(),
}),
execute: async ({ latitude, longitude, city }) => {
const temperature = Math.round(Math.random() * (90 - 32) + 32);
const humidity = Math.round(Math.random() * (100 - 30) + 30);
const weatherCode = Math.floor(Math.random() * 50) + 1;
return {
temperature,
weatherCode,
humidity,
city,
};
},
}),
},
});
return result.toDataStreamResponse();
}回到 terminal 並詢問舊金山的天氣。
What's the weather in San Francisco?一個空白回應……這是因為模型生成了一個工具呼叫而不是訊息。讓我們在 UI 中呈現工具呼叫和結果。
回到你的 page.tsx,並添加以下程式碼來呈現工具呼叫和結果:
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.toolInvocations ? (
<pre>{JSON.stringify(m.toolInvocations, null, 2)}</pre>
) : (
<p>{m.content}</p>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}儲存後,跳回瀏覽器,如果頁面還沒有刷新,你應該能在 UI 中看到工具回傳的結果。
模型現在能夠呼叫工具來獲取回答問題所需的信息。然而,模型在獲取信息後並未回答用戶的問題,這是為什麼呢?
原因是模型技術上已經完成了它的生成,因為它生成了一個工具呼叫。為了讓模型在獲取信息後回答用戶的問題,我們需要將結果與原始問題一起反饋給模型。我們可以通過配置模型可以執行的最大步驟數來實現這一點。默認情況下,maxSteps 設置為 1。
更新你的 page.tsx 以在聊天機器人中添加多個步驟:
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
maxSteps: 5,
});
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.toolInvocations ? (
<pre>{JSON.stringify(m.toolInvocations, null, 2)}</pre>
) : (
<p>{m.content}</p>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}回到瀏覽器,再次詢問天氣。注意,現在模型在獲取相關信息後就會回答用戶的問題了。
做到現在,我們已經完成了我們 chatbot 的資料流。現在我們要將我們的資料轉化成優美的 UI。
為了做到這一點,我們可以遍歷已經顯示在 UI 中的 toolInvocations。如果 toolName 等於 "getWeather",我們將結果傳遞到 Weather 組件作為 props。
以下是更新後的 page.tsx 程式碼:
"use client";
import { useChat } from "ai/react";
import Weather from "./weather";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
maxSteps: 5,
});
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.toolInvocations ? (
m.toolInvocations.map((t) =>
t.toolName === "getWeather" && t.state === "result" ? (
<Weather key={t.toolCallId} weatherData={t.result} />
) : null,
)
) : (
<p>{m.content}</p>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}回到瀏覽器並試試看。詢問特定位置的天氣,看看 UI 如何動態生成更具吸引力的天氣數據表示。
現在,當你請求天氣信息時,你應該會看到一個視覺上吸引人的天氣組件,而不是原始的 JSON 數據。
你可能還想讓這個元件能與聊天室互動並觸發後續的生成!例如,你可以新增一個按鈕來獲取隨機城市的天氣。為了實現這個功能,請在你的 useChat hook 上設置一個 id,這樣就能在應用程式的其他元件中使用這個 hook。
請更新你的 page.tsx 文件,並使用以下代碼:
"use client";
import { useChat } from "ai/react";
import Weather from "./weather";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
id: "weather",
maxSteps: 5,
});
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map((m) => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === "user" ? "User: " : "AI: "}
{m.toolInvocations ? (
m.toolInvocations.map((t) =>
t.toolName === "getWeather" && t.state === "result" ? (
<Weather key={t.toolCallId} weatherData={t.result} />
) : null,
)
) : (
<p>{m.content}</p>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}現在更新天氣元件,導入並使用 useChat hook,當按鈕被點擊時觸發新的天氣請求。
import { useChat } from "ai/react";
import {
Cloud,
Sun,
CloudRain,
CloudSnow,
CloudFog,
CloudLightning,
} from "lucide-react";
import { useState } from "react";
export interface WeatherData {
city: string;
temperature: number;
weatherCode: number;
humidity: number;
}
const defaultWeatherData: WeatherData = {
city: "San Francisco",
temperature: 18,
weatherCode: 1,
humidity: 65,
};
export default function Weather({
weatherData = defaultWeatherData,
}: {
weatherData?: WeatherData;
}) {
console.log(weatherData);
const { append } = useChat({ id: "weather" });
const [clicked, setClicked] = useState(false);
const getWeatherIcon = (code: number) => {
switch (true) {
case code === 0:
return <Sun size={64} className="text-yellow-300" />;
case code <= 3:
return (
<div className="relative">
<Sun size={64} className="text-yellow-300" />
<Cloud
size={48}
className="text-gray-300 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
/>
</div>
);
case code <= 49:
return <Cloud size={64} className="text-gray-300" />;
case code <= 69:
return <CloudRain size={64} className="text-blue-300" />;
case code <= 79:
return <CloudSnow size={64} className="text-blue-200" />;
case code <= 84:
return <CloudRain size={64} className="text-blue-300" />;
case code <= 99:
return <CloudLightning size={64} className="text-yellow-400" />;
default:
return <Cloud size={64} className="text-gray-300" />;
}
};
const getWeatherCondition = (code: number) => {
switch (true) {
case code === 0:
return "Clear sky";
case code <= 3:
return "Partly cloudy";
case code <= 49:
return "Cloudy";
case code <= 69:
return "Rainy";
case code <= 79:
return "Snowy";
case code <= 84:
return "Rain showers";
case code <= 99:
return "Thunderstorm";
default:
return "Unknown";
}
};
return (
<div className="text-white p-8 rounded-3xl backdrop-blur-lg bg-gradient-to-b from-blue-400 to-blue-600 shadow-lg">
<button
disabled={clicked}
onClick={async () => {
setClicked(true);
append({ role: "user", content: "Get weather in a random place" });
}}
>
{clicked ? "Clicked" : "Click me"}
</button>
<h2 className="text-4xl font-semibold mb-2">{weatherData.city}</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-6xl font-light">{weatherData.temperature}°C</p>
<p className="text-xl mt-1">
{getWeatherCondition(weatherData.weatherCode)}
</p>
</div>
<div className="ml-8" aria-hidden="true">
{getWeatherIcon(weatherData.weatherCode)}
</div>
</div>
<div className="mt-6 flex items-center">
<CloudFog size={20} aria-hidden="true" />
<span className="ml-2">Humidity: {weatherData.humidity}%</span>
</div>
</div>
);
}現在回到瀏覽器並嘗試點擊天氣元件上的按鈕。你應該會在聊天視窗中看到一條新訊息,這會觸發後續的生成!
終於結束啦!這次的 Workshop 我獲益良多,希望你也是~ 🙂
