ChatGPT APIとNext.jsでお手軽チャットアプリ作成
Next.js と ChatGPT API で作る面白いチャット体験
こんにちは、エンジニアの皆さん!今回は、Next.js を使って ChatGPT API を組み込んだチャットアプリを作成してみたいと思います。(GPT から取ってきた挨拶)
1 日あればそれっぽい良い感じのチャットアプリが出来てしまうので気軽に試してみてください!
実際のデモは以下のような感じ ↓
リポジトリはこちら ↓
準備
プロジェクトを始める前に、以下のツールとライブラリをインストールします:
- Node.js
- Next.js
- TailwindCSS
- Chakra UI
- Framer Motion
- OpenAI node.js library
手順
1. プロジェクトのセットアップ
まずは、Next.js
とTailwindCSS
を使った新しいプロジェクトを作成していきましょう。
以下のコマンドでNext.js
とtailwindCSS
のインストールを行います。
ちなみに今回は新しく導入された app ディレクトリを使っています。
npx create-next-app@latest chat-app
cd chat-app
npm install tailwindcss@latest postcss@latest autoprefixer@latest
次に、必要なパッケージをインストールします。今回はChakra UI
とFramer Motion
を使用します。Chakra UI
はReact
のコンポーネントライブラリで、簡単にスタイリングできるコンポーネントが用意されています。Framer Motion
はアニメーションライブラリで、コンポーネントのアニメーションを簡単に追加できます。以下のコマンドでパッケージをインストールします。
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion
TailwindCSS の細かい設定を行います。
まず、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
に以下を追加します。
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
最後に OpenAI が作成した Node.js 用のライブラリーをインストールします。
npm install openai
2. ChatGPT API とのやり取り
大本命の ChatGPT API を使ったメッセージの取得と送信の処理を書いていきます。
まず、app ディレクトリと同階層にpages/api/messages/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
}
}
role
はuser
・assistant
・system
の 3 種類があり、API に送るメッセージがどんな人からのものであったり、どんな役割を持つかを示すものになります。それぞれの使い方は以下です。
- user:GPT にメッセージを送る人、つまり私たちのこと
- assistant:GPT のこと。GPT から返ってくるメッセージにはこの role が付与されている
- system:GPT へのプロンプトのようなもの。「~のように振舞ってください」のような指示を与える role
最後に、環境変数の設定を行います。
まず、ルートディレクトリに.env.local
ファイルを作成して、中身を以下のようにすれば OK です。
OPENAI_API_KEY=あなたのAPIKey;
APIKey はここから作れます。
これで API とのやり取り処理の追加は完了です!
3.ページの作成
次に、app ディレクトリ直下に page.tsx
ファイルを作成し、Home ページを定義します。このページでは、チャットエリアとメッセージ入力フォームを表示します。
Home ページでは、以下の処理を行います。
-
Chat
コンポーネントを使ってチャット履歴を表示 -
InputForm
コンポーネントを使って入力ホームを表示 - チャットの状態を管理するために
useState
を使用 - メッセージ送信中の状態を管理するために、
isSubmitting
という状態を作成 -
handleSubmit
関数を定義し、InputForm
コンポーネントに渡す。この関数で、ユーザーが入力したメッセージを API エンドポイントに送信し、返答を受け取ってチャット履歴に追加 - メッセージ送信中のローディングアニメーションを表示
"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 としてcontent
とrole
を受け取れるようにします。
import { Message } from "../types/custom";
const Chat = ({ role, content }: Message) => {
return <></>;
};
export default Chat;
Message 型は広く使うため、types 配下に定義してグローバルに参照できるようにします。
app ディレクトリ配下に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 を表示する。
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 の中身
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 の中身
"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 点ローダーコンポーネント
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.tsx
とMain.tsx
、Header.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>
);
}
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;
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 でも作ってもらえそうです!
参考
Discussion