🙉

ChatGPT APIとNext.jsでお手軽チャットアプリ作成

2023/03/19に公開

Next.js と ChatGPT API で作る面白いチャット体験

こんにちは、エンジニアの皆さん!今回は、Next.js を使って ChatGPT API を組み込んだチャットアプリを作成してみたいと思います。(GPT から取ってきた挨拶)
1 日あればそれっぽい良い感じのチャットアプリが出来てしまうので気軽に試してみてください!

実際のデモは以下のような感じ ↓
チャットデモ

リポジトリはこちら ↓
https://github.com/yajium/gptapi-chat

準備

プロジェクトを始める前に、以下のツールとライブラリをインストールします:

  • Node.js
  • Next.js
  • TailwindCSS
  • Chakra UI
  • Framer Motion
  • OpenAI node.js library

手順

1. プロジェクトのセットアップ

まずは、Next.jsTailwindCSSを使った新しいプロジェクトを作成していきましょう。
以下のコマンドでNext.jstailwindCSSのインストールを行います。
ちなみに今回は新しく導入された app ディレクトリを使っています。

bash
npx create-next-app@latest chat-app
cd chat-app
npm install tailwindcss@latest postcss@latest autoprefixer@latest

次に、必要なパッケージをインストールします。今回はChakra UIFramer Motionを使用します。Chakra UIReactのコンポーネントライブラリで、簡単にスタイリングできるコンポーネントが用意されています。Framer Motionはアニメーションライブラリで、コンポーネントのアニメーションを簡単に追加できます。以下のコマンドでパッケージをインストールします。

bash
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion

TailwindCSS の細かい設定を行います。
まず、tailwind.config.jsに以下を追加します。

tailwind.config.js
module.exports = {
  content: [
+    "./app/**/*.{js,ts,jsx,tsx}",
+    "./pages/**/*.{js,ts,jsx,tsx}",
+    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

globals.cssに以下を追加します。

globals.css
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;

最後に OpenAI が作成した Node.js 用のライブラリーをインストールします。

npm install openai

2. ChatGPT API とのやり取り

大本命の ChatGPT API を使ったメッセージの取得と送信の処理を書いていきます。
まず、app ディレクトリと同階層にpages/api/messages/index.tsを作成し、中身を以下のようにします。

index.ts
import { NextApiRequest, NextApiResponse } from "next";
import { Configuration, OpenAIApi } from "openai";

// 発行したAPI Keyを使って設定を定義
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (!configuration.apiKey) {
    res.status(500).json({
      error: {
        message:
          "OpenAI API key not configured, please follow instructions in README.md",
      },
    });
    return;
  }

  // GPTに送るメッセージを取得
  const message = req.body.message;

  try {
    // 設定を諸々のせてAPIとやり取り
    const completion = await openai.createChatCompletion({
      model: "gpt-3.5-turbo",
      messages: message,
      temperature: 0.9,
      max_tokens: 100,
    });
    // GPTの返答を取得
    res.status(200).json({ result: completion.data.choices[0].message });
  } catch (error: any) {
    // Consider adjusting the error handling logic for your use case
    if (error.response) {
      console.error(error.response.status, error.response.data);
      res.status(error.response.status).json(error.response.data);
    } else {
      console.error(`Error with OpenAI API request: ${error.message}`);
      res.status(500).json({
        error: {
          message: "An error occurred during your request.",
        },
      });
    }
  }
}
ChatGPT API とやり取りするときに必要な設定の詳細
const { Configuration, OpenAIApi } = require("openai");

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY, // .env.localで定義する
});
const openai = new OpenAIApi(configuration);

const completion = await openai.createChatCompletion({
  model: "gpt-3.5-turbo", // 使うモデル
  messages: messages, // 今までの履歴も含めたメッセージ集
  temperature: 0.9, // ChatGPT の発言の多様さ・ランダム度合い
  max_tokens: 200, // GPTから返ってくる最大トークン数の制限 少ないほど費用を抑えられる
});
console.log(completion.data.choices[0].message);
ChatGPT API からのレスポンス詳細

今回はmessageにあるオブジェクトを取得します

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "\n\nHello there, how may I assist you today?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}

roleuserassistantsystemの 3 種類があり、API に送るメッセージがどんな人からのものであったり、どんな役割を持つかを示すものになります。それぞれの使い方は以下です。

  • user:GPT にメッセージを送る人、つまり私たちのこと
  • assistant:GPT のこと。GPT から返ってくるメッセージにはこの role が付与されている
  • system:GPT へのプロンプトのようなもの。「~のように振舞ってください」のような指示を与える role

最後に、環境変数の設定を行います。
まず、ルートディレクトリに.env.localファイルを作成して、中身を以下のようにすれば OK です。

.env.local
OPENAI_API_KEY=あなたのAPIKey;

APIKey はここから作れます。
これで API とのやり取り処理の追加は完了です!

3.ページの作成

次に、app ディレクトリ直下に page.tsx ファイルを作成し、Home ページを定義します。このページでは、チャットエリアとメッセージ入力フォームを表示します。

Home ページでは、以下の処理を行います。

  • Chat コンポーネントを使ってチャット履歴を表示
  • InputForm コンポーネントを使って入力ホームを表示
  • チャットの状態を管理するためにuseStateを使用
  • メッセージ送信中の状態を管理するために、isSubmitting という状態を作成
  • handleSubmit関数を定義し、InputFormコンポーネントに渡す。この関数で、ユーザーが入力したメッセージを API エンドポイントに送信し、返答を受け取ってチャット履歴に追加
  • メッセージ送信中のローディングアニメーションを表示
page.tsx
"use client";

const Home: NextPage = () => {
  return (
    <div className="w-full max-w-2xl bg-white md:rounded-lg md:shadow-md p-4 md:p-10 my-10">
        // この中に入力フォームとチャット履歴を表示するコンポーネントを作成する
    </div>
  );
};

export default Home;

app ディレクトリの中ではデフォルトで ServerCOmponents となるため useState や useEffect など ClientSide の機能を使いたいときは"use client";を追加する必要があります。
今回はこの 1 ページだけで ClientSideRendering だけ必要なので、app ディレクトリの機能は結果的に不要でした。

3-1. チャット表示コンポーネントの作成

まず、Chat コンポーネントを作成するため、app ディレクトリ配下にcomponents/Chat.tsxを追加します。
このコンポーネントでは、自分または GPT 側の 1 メッセージを受け取って、それを表示するように設計したいので、Props としてcontentroleを受け取れるようにします。

Chat.tsx
import { Message } from "../types/custom";

const Chat = ({ role, content }: Message) => {
  return <></>;
};

export default Chat;

Message 型は広く使うため、types 配下に定義してグローバルに参照できるようにします。
app ディレクトリ配下にtypes/custom.d.tsを作成します。

types/custom.d.ts
export type Message = {
  role: "system" | "assistant" | "user";
  content: string;
};

そして、Chat.tsxの中身を ChakuraUI を使って LINE のような UI にするのと同時に、Framer Motion で実際の ChatGPT のように文字が左から右に次々と現れるような見た目にします。

最終的な Chat.tsx の中身
  • motion.div:メッセージが表示されるアニメーションを定義。メッセージは、透明度が 0 から 1 に変わり、Y 軸方向に移動して表示される。
  • Flexコンポーネント:アバターとメッセージを配置する。アバターは、役割に応じて異なる画像が表示される。
  • アシスタントの場合は chatMessage を、ユーザーの場合は content を表示する。
Chat.tsx
import { Avatar, Flex } from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { Message } from "../types/custom";

const Chat = ({ content, role }: Message) => {
  const [chatMessage, setChatMessage] = useState(""); // 現在表示されているメッセージを保持
  const [currentIndex, setCurrentIndex] = useState(0); // 次に表示する文字のインデックスを保持

  useEffect(() => {
    if (currentIndex < content.length) {
      // メッセージを1文字ずつ表示する。アシスタントのメッセージのみで使用
      const timeoutId = setTimeout(() => {
        // メッセージの次の文字を追加し、chatStringIndexを更新
        setChatMessage((prevText) => prevText + content[currentIndex]);
        setCurrentIndex((prevIndex) => prevIndex + 1);
      }, 80);

      return () => {
        clearTimeout(timeoutId);
      };
    }
  }, [content, currentIndex]);

  return (
    <motion.div
      style={{
        alignSelf: role === "assistant" ? "flex-start" : "flex-end",
        width: "auto",
      }}
      initial={{
        opacity: 0,
        translateY: "100%",
      }}
      animate={{ opacity: 1, translateY: 0, transition: { duration: 0.3 } }}
      exit={{ opacity: 0, translateY: 0 }}
    >
      <Flex
        gap="5px"
        w="full"
        flexDir={role === "assistant" ? "row" : "row-reverse"}
        mt="10"
      >
        <Avatar
          name={role === "user" ? "Me" : "GPT"}
          w="40px"
          h="40px"
          src={
            role === "assistant"
              ? "https://emoji-img.s3.ap-northeast-1.amazonaws.com/svg/1f609.svg"
              : "https://emoji-img.s3.ap-northeast-1.amazonaws.com/svg/1f47c.svg"
          }
        />
        <Flex
          borderWidth={1}
          borderColor="blue.400"
          bg="main-bg"
          p="0.5rem 1rem"
          w="auto"
          mt="16"
          rounded={
            role === "assistant" ? "0 20px 20px 20px" : "20px 0 20px 20px"
          }
          fontSize={{ base: "8px", md: "18px" }}
          flexDir="column"
        >
          {role === "assistant" && (
            <Flex
              alignSelf="flex-end"
              fontStyle="italic"
              opacity={0.4}
              fontSize="8px"
              as="small"
              fontWeight={500}
            >
              GPT
            </Flex>
          )}
          {role === "user" && (
            <Flex
              alignSelf="flex-start"
              fontStyle="italic"
              opacity={0.4}
              fontSize="8px"
              as="small"
              fontWeight={500}
            >
              あなた
            </Flex>
          )}
          {role === "assistant" ? chatMessage || "" : content || ""}
        </Flex>
      </Flex>
    </motion.div>
  );
};

export default Chat;

3-2. メッセージ入力フォームの作成

components/InputForm.tsxファイルを作成し、メッセージを入力するための InputForm コンポーネントを作成します。このコンポーネントでは、入力されたメッセージを onSubmit 関数に渡し、送信処理が行われます。

InputForm.tsx の中身
InputForm.tsx
import React, { useRef } from "react";
import { Message } from "../types/custom";

type InputFormProps = {
  onSubmit: (message: Message) => Promise<void>; // onSUbmit関数は親コンポーネントで提供され、ユーザーがメッセージを送信すると呼び出される
};

const InputForm = ({ onSubmit }: InputFormProps) => {
  // input要素への参照を作成
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // input要素から直接値を取得
    const inputValue = inputRef.current?.value;

    if (inputValue) {
      // 親コンポーネントから提供されたonSubmit関数を介して送信されたメッセージを処理
      onSubmit({
        role: "user",
        content: inputValue,
      });
      inputRef.current.value = "";
    }
  };

  return (
    <form
      onSubmit={handleSubmit}
      className="flex items-center p-4 border-t border-gray-200"
    >
      <input
        type="text"
        ref={inputRef}
        className="flex-grow px-4 py-2 border rounded-lg focus:outline-none focus:ring focus:border-blue-300"
        placeholder="メッセージを入力..."
      />
      <button
        type="submit"
        className="ml-4 px-4 py-2 bg-blue-500 text-white rounded-lg focus:outline-none focus:ring focus:border-blue-300"
      >
        送信
      </button>
    </form>
  );
};

export default InputForm;

3-3. Home ページの完成

これでapp/page.tsxに Chat コンポーネントと InputForm コンポーネントを追加することが出来ます。

page.tsx の中身
page.tsx
"use client";

import { Flex } from "@chakra-ui/react";
import type { NextPage } from "next";
import { useState } from "react";
import { AnimatePresence } from "framer-motion";
import Chat from "./components/Chat";
import InputForm from "./components/InputForm";
import { Message } from "./types/custom";
import ThreeDotsLoader from "./components/ThreeDotsLoader";
import { siteTitle, system_prompt } from "./constants/constants";

const Home: NextPage = () => {

  // chats:メッセージのリストを保持。初期値としてシステムメッセージ(system_prompt)を入れておく
  const [chats, setChats] = useState<Message[]>([
    {
      role: "system",
      content: system_prompt,
    },
  ]);
  // isSubmitting: メッセージ送信中かどうかのフラグ。GPTの返答待ちの間「・・・」のアニメーションを表示
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (message: Message) => {
    try {
      setIsSubmitting(true);
      setChats((prev) => [...prev, message]);

      // ChatGPT APIと通信
      const response = await fetch("/api/messages", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          message: [...chats, message].map((d) => ({
            role: d.role,
            content: d.content,
          })),
        }),
      });

      const data = await response.json();
      if (response.status !== 200) {
        throw (
          data.error ||
          new Error(`Request failed with status ${response.status}`)
        );
      }
      setChats((prev) => [...prev, data.result as Message]);
    } catch (error) {
      console.log(error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="w-full max-w-2xl bg-white md:rounded-lg md:shadow-md p-4 md:p-10 my-10">
      <div className="mb-10">
        <AnimatePresence>
          {chats.slice(1, chats.length).map((chat, index) => {
            return <Chat role={chat.role} content={chat.content} key={index} />;
          })}
        </AnimatePresence>
      </div>
      <InputForm onSubmit={handleSubmit} />
    </div>
  );
};

export default Home;

chats.slice()の部分はchatsで保持しているメッセージたちの 1 番最初にsystem用のメッセージを含むため、それを除いたものを表示するようにしています。

GPT API からの返答待ち時に表示する 3 点ローダーコンポーネント
components/ThreeDotsLoader.tsx
const ThreeDotsLoader = () => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      xmlnsXlink="http://www.w3.org/1999/xlink"
      style={{
        margin: "auto",
        background: "none",
        display: "block",
        shapeRendering: "auto",
      }}
      width="30px"
      height="30px"
      viewBox="0 0 100 100"
      preserveAspectRatio="xMidYMid"
    >
      <circle cx="84" cy="50" r="10" fill="rgba(111, 116, 96, 1)">
        <animate
          attributeName="r"
          repeatCount="indefinite"
          dur="0.7352941176470588s"
          calcMode="spline"
          keyTimes="0;1"
          values="10;0"
          keySplines="0 0.5 0.5 1"
          begin="0s"
        ></animate>
        <animate
          attributeName="fill"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="discrete"
          keyTimes="0;0.25;0.5;0.75;1"
          values="rgba(255, 255, 255, 0.4);rgba(255, 255, 255, 0.9999);rgba(255, 255, 255, 0.8);"
          begin="0s"
        ></animate>
      </circle>
      <circle cx="16" cy="50" r="10" fill="rgba(173, 174, 169, 1)">
        <animate
          attributeName="r"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="0;0;10;10;10"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="0s"
        ></animate>
        <animate
          attributeName="cx"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="16;16;16;50;84"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="0s"
        ></animate>
      </circle>
      <circle cx="50" cy="50" r="10" fill="rgba(43, 46, 32, 1)">
        <animate
          attributeName="r"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="0;0;10;10;10"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-0.7352941176470588s"
        ></animate>
        <animate
          attributeName="cx"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="16;16;16;50;84"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-0.7352941176470588s"
        ></animate>
      </circle>
      <circle cx="84" cy="50" r="10" fill="rgba(64, 73, 30, 1)">
        <animate
          attributeName="r"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="0;0;10;10;10"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-1.4705882352941175s"
        ></animate>
        <animate
          attributeName="cx"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="16;16;16;50;84"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-1.4705882352941175s"
        ></animate>
      </circle>
      <circle cx="16" cy="50" r="10" fill="rgba(111, 111, 111, 1)">
        <animate
          attributeName="r"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="0;0;10;10;10"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-2.205882352941176s"
        ></animate>
        <animate
          attributeName="cx"
          repeatCount="indefinite"
          dur="2.941176470588235s"
          calcMode="spline"
          keyTimes="0;0.25;0.5;0.75;1"
          values="16;16;16;50;84"
          keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1"
          begin="-2.205882352941176s"
        ></animate>
      </circle>
    </svg>
  );
};

export default ThreeDotsLoader;

4. レイアウトやヘッダーの作成

一般的な app ディレクトリの扱い方と同様に app ディレクトリ直下にLayout.tsxMain.tsxHeader.tsxを作成します。

ここでは詳しい説明は省きます。

app/Layout.tsx
import "./globals.css";
import Header from "./Header";
import Main from "./Main";

export const metadata = {
  title: "ChatGPTとおしゃべり",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head />
      <body className="min-h-screen bg-white md:bg-gray-100">
        <Header />
        <Main>{children}</Main>
      </body>
    </html>
  );
}
Header.tsx
import { siteTitle } from "./constants/constants";

const Header = () => {
  return (
    <header className="text-center py-5 bg-white shadow-md sticky top-0 z-10">
      <h1 className="text-lg md:text-3xl font-semibold px-4 whitespace-pre-line">
        {siteTitle}
      </h1>
    </header>
  );
};

export default Header;
Main.tsx
export default function Main({ children }: { children: React.ReactNode }) {
  return (
    <main className="min-h-screen flex flex-grow items-center justify-center">
      {children}
    </main>
  );
}

完成!

これで、お手軽チャットアプリは動くようになったかと思います。
今回私が作った目的としては、大好きなぬいぐるみと話せたらなあという思いがあり作ってみました。実際に愛着を持って使えてるのでいい感じです 🍮
ちなみに基本的なデザインは ChatGPT に考えてもらって作りました。
TailwindCSS を使っていますが、シンプルなものなら今の古い情報しかない GPT でも作ってもらえそうです!

参考

https://platform.openai.com/docs/api-reference/chat/create
https://awacreates.com/blog/build-a-chatbot-using-gpt-3s-api-and-nextjs
https://zenn.dev/azukiazusa/articles/next-js-app-dir-tutorial#記事の作成

GitHubで編集を提案

Discussion