🐥

Vercel AI SDK の Quickstart で AI Chatボットアプリを構築(Server Actionsを利用)

2024/05/17に公開

はじめに

前回の記事で Vercel AI SDK の Quickstart を参考に AI Chat ボットアプリを構築しました。今回は、Route Handler ではなく、Server Actions を利用して実装します。

こちらの内容を参考にしながら Server Actions 化を進めます。

https://sdk.vercel.ai/docs/getting-started/nextjs-app-router#introducing-airsc

リポジトリをクローン

こちらのリポジトリーをクローンします。

https://github.com/hayato94087/next-vercel-ai-sample

Server Actionを作成

Server Action を作成します。

$ mkdir -p src/app/actions/
$ touch src/app/actions/conversation.ts
$ touch src/app/actions/conversation-stream.ts
src/app/actions/conversation.ts
'use server';

import { createStreamableValue } from 'ai/rsc';
import { CoreMessage, streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function continueConversation(messages: CoreMessage[]) {
  const result = await streamText({
    model: openai('gpt-3.5-turbo'),
    messages,
  });

  const stream = createStreamableValue(result.textStream);
  return stream.value;
}
src/app/actions/conversation-stream.ts
'use server';

import { createStreamableValue } from 'ai/rsc';
import { CoreMessage, streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function continueConversation(messages: CoreMessage[]) {
  'use server';
  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages,
  });
  const data = { test: 'hello' };
  const stream = createStreamableValue(result.textStream);
  return { message: stream.value, data };
}

UIを更新

UI を更新します。

src/app/components/chat.tsx
"use client";

import { useState, type FC } from "react";
import { continueConversation } from "../actions/conversation";
import { CoreMessage } from "ai";
import { readStreamableValue } from "ai/rsc";

type ChatProps = {};

export const Chat: FC<ChatProps> = ({}) => {
  const [messages, setMessages] = useState<CoreMessage[]>([]);
  const [input, setInput] = useState("");
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map((m, i) => (
        <div key={i} className="whitespace-pre-wrap">
          {m.role === "user" ? "User: " : "AI: "}
          {m.content as string}
        </div>
      ))}

      <form
        action={async () => {
          const newMessages: CoreMessage[] = [
            ...messages,
            { content: input, role: "user" },
          ];

          setMessages(newMessages);
          setInput("");

          const result = await continueConversation(newMessages);

          for await (const content of readStreamableValue(result)) {
            setMessages([
              ...newMessages,
              {
                role: "assistant",
                content: content as string,
              },
            ]);
          }
        }}
      >
        <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={(e) => setInput(e.target.value)}
        />
      </form>
    </div>
  );
};

コミットします。

$ git add .
$ git commit -m "feat: add server actions"

UIを更新

UI を更新します。

src/app/components/chat.tsx
"use client";

import { useState, type FC } from "react";
import { continueConversation } from "../actions/conversation";
import { CoreMessage } from "ai";
import { readStreamableValue } from "ai/rsc";

type ChatProps = {};

export const Chat: FC<ChatProps> = ({}) => {
  const [messages, setMessages] = useState<CoreMessage[]>([]);
  const [input, setInput] = useState("");
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map((m, i) => (
        <div key={i} className="whitespace-pre-wrap">
          {m.role === "user" ? "User: " : "AI: "}
          {m.content as string}
        </div>
      ))}

      <form
        action={async () => {
          const newMessages: CoreMessage[] = [
            ...messages,
            { content: input, role: "user" },
          ];

          setMessages(newMessages);
          setInput("");

          const result = await continueConversation(newMessages);

          for await (const content of readStreamableValue(result)) {
            setMessages([
              ...newMessages,
              {
                role: "assistant",
                content: content as string,
              },
            ]);
          }
        }}
      >
        <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={(e) => setInput(e.target.value)}
        />
      </form>
    </div>
  );
};
src/app/components/chat-stream.tsx
"use client";

import { useState, type FC } from "react";
import { continueConversation } from "../actions/conversation-stream";
import { CoreMessage } from "ai";
import { readStreamableValue } from "ai/rsc";

type ChatProps = {};

export const Chat: FC<ChatProps> = ({}) => {
  const [messages, setMessages] = useState<CoreMessage[]>([]);
  const [input, setInput] = useState("");
  const [data, setData] = useState<any>();
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
      {messages.map((m, i) => (
        <div key={i} className="whitespace-pre-wrap">
          {m.role === "user" ? "User: " : "AI: "}
          {m.content as string}
        </div>
      ))}

      <form
        action={async () => {
          const newMessages: CoreMessage[] = [
            ...messages,
            { content: input, role: "user" },
          ];

          setMessages(newMessages);
          setInput("");

          const result = await continueConversation(newMessages);
          setData(result.data);

          for await (const content of readStreamableValue(result.message)) {
            setMessages([
              ...newMessages,
              {
                role: "assistant",
                content: content as string,
              },
            ]);
          }
        }}
      >
        <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={(e) => setInput(e.target.value)}
        />
      </form>
    </div>
  );
};

アプリケーションを実行

作成したアプリケーションの動作を確認します。

$ pnpm run dev

ブラウザで http://localhost:3000/ にアクセスします。

https://x.com/hayato94087/status/1791380732855947771

ブラウザで http://localhost:3000/stream にアクセスします。

https://x.com/hayato94087/status/1791566494083747896

さいごに

前回の作業リポジトリに対して Server Actions 追加しました。

作業リポジトリはこちらです。

https://github.com/hayato94087/next-vercel-ai-server-actions-sample

Discussion