Vercel AI SDK (Part 3)

文字總結與提取結構化資訊

eric

最後更新時間

2025年2月3日

文章列表

概述

本篇為系列文章的第三部分,也是最後一個部分。請讀者先看過前面兩個部分再閱讀本篇教學。前面內容可以點擊下面連結

本篇教學會說明如何透過 Vercel AI SDK 進行 Chatbot 的實作。

建構 chatbot

api/chat/route.ts 中定義一個 streamText 的路由處理器。如果部署在 Vercel 上,請記得將最大持續時間設置為大於 10 秒的值。

app/(5-chatbot)/api/chat/route.ts
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
 
export async function POST(req: Request) {}

從 request body 中獲取傳入的訊息。

app/(5-chatbot)/api/chat/route.ts
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
 
export async function POST(req: Request) {
  const { messages } = await req.json(); 
}

呼叫 streamText,並傳入你的模型和訊息。

app/(5-chatbot)/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"),
    messages,
  });
}

將生成的結果作為流式響應返回。

app/(5-chatbot)/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"),
    messages,
  });
 
  return result.toDataStreamResponse(); 
}

在你的 chat.tsx 檔案中,從 ai/react 匯入 useChat 的 hook。

app/(5-chatbot)/chat/page.tsx
"use client"; 
 
import { useChat } from "ai/react"; 
 
export default function Chat() {
  const {} = useChat(); 
  return <div>Chatbot</div>;
}

解構 messages 並遍歷它們來顯示聊天訊息。

app/(5-chatbot)/chat/page.tsx
"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,得到 inputhandleInputChangehandleSubmit。並新增一個輸入欄位和一個表單來提交訊息。

app/(5-chatbot)/chat/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.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 檔案,並新增一個系統提示來改變模型的回應方式。

app/(5-chatbot)/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();
}

回到瀏覽器並提出一個新的問題,以查看新的回應。注意,我們完全改變了模型的行為,而不需要改變模型本身。

最後一步!將系統提示更改為你喜歡的任何內容。

app/(5-chatbot)/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 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 幫助函數來定義這個工具。

app/(5-chatbot)/api/chat/route.ts
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();
}

首先,你需要為你的工具添加描述。這是模型用來決定何時使用該工具的依據。

app/(5-chatbot)/api/chat/route.ts
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。

app/(5-chatbot)/api/chat/route.ts
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();
}

我們可以利用模型的生成能力來定義可以從對話中推斷出來的參數。在這個情況下,我們需要該地點的緯度、經度和城市名稱。我們預期使用者會提供城市名稱,而模型則可以根據這個城市名稱生成緯度和經度。

最後,我們定義一個執行函數。這段代碼會在工具被呼叫時執行。

app/(5-chatbot)/api/chat/route.ts
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}&current=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();
}

回到 terminal 並詢問舊金山的天氣。

What's the weather in San Francisco?

一個空白回應……這是因為模型生成了一個工具呼叫而不是訊息。讓我們在 UI 中呈現工具呼叫和結果。

回到你的 page.tsx,並添加以下程式碼來呈現工具呼叫和結果:

app/(5-chatbot)/chat/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 以在聊天機器人中添加多個步驟:

app/(5-chatbot)/chat/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 程式碼:

app/(5-chatbot)/chat/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 文件,並使用以下代碼:

app/(5-chatbot)/chat/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,當按鈕被點擊時觸發新的天氣請求。

app/(5-chatbot)/chat/weather.tsx
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 我獲益良多,希望你也是~ 🙂