Next.js × ChatGPT-API で作るチャットアプリ

# はじめに
皆さん、AIは使ってますか？
最近はAIを使ってイラストやソースコードなどを生成できるようになり、意識せずともその技術に触れる機会が多くなりました。
特にChatGPTと呼ばれるチャットアプリは無料で気軽に試すことができるので、すでに触ったことのある方もいらっしゃると思います。

さて、今回の題材である ChatGPT-API は openAI が提供する API なのですが、これを利用すると自作アプリに ChatGPT を簡単に導入できるそうです。
折角なので流行りに乗ってみようと next13 の勉強がてらに実装してみたので、そこで得たノウハウを今回記事にまとめてみました。
ChatGPT-APIについて気になっている方はぜひ参考にしてみてください。

# 注意事項
・ChatGPT-API が本題なので、recoilやreact-hook-formなど使っていますが
　それらの技術要素は説明を省きます。
・ChatGPT-API のコストですが、**体験版終了後はお金かかります**
・以下の記事に沿って実装を進めています。
　APIに付けるオプションをユーザに設定させたかったので構成は一部変わっていますが、
　勉強するならこちらの方がよりシンプルでわかりやすいと思います。
　　[ChatGPT APIとNext.jsでお手軽チャットアプリ作成](https://zenn.dev/rorisutarou/articles/6f21450ea34cc3)
・Git Hubのソースコードは[こちら](https://github.com/YukiTsukazakiSys/next-chatgpt)からご覧ください。

# 構成
#### ディレクトリ構成
以下の通りです。
>public
　→会話画面で使うアイコンを配置
src
　└app
　　→Layoutや各画面を配置（next13に準拠）
　└assets
　　→グローバルcssを配置
　└atom
　　→recoil用ファイルを配置
　└components
　　→会話画面で利用するコンポーネントを配置
　└constants
　　→定数ファイルを配置
　└pages / api / messages
　　→ChatGPT APIを実行する関数を定義したファイルを配置

#### タイトル画面
アプリのタイトル画面です。
prompt入力画面へ ボタンを押すと、当然ですがprompt入力画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822485/1e9e5978-5263-3282-50b6-7406eec6e79f.png)

#### prompt入力画面
この画面で、API実行時のリクエストパラメータを設定します。
このパラメータを設定することで、ChatGPTの振る舞いをある程度制御することが可能です。
本アプリでは設定できるパラメータを絞っていますが、もっと知りたい方は以下を参照ください。
[Create chat completion - OpenAI API](https://platform.openai.com/docs/api-reference/chat/create)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822485/bc9926ce-62eb-3dc2-c5ee-bf6169ba310b.png)
start! ボタンを押下すると会話画面に遷移します。

以下は設定できる項目の説明です。※必須、任意はアプリ側で設定しています
**▽prompt(必須)**
chatGPT側でどのようなふるまいをするかを設定します。「友人のように～」や、「語尾にざますをつけて～」といった指定が出来ます。
**▽max_tokens(任意)**
１回のやり取りのトークン上限を設定することが出来ます。chatGPT APIはトークン単位で料金が発生するので、上限を設定することで思わぬ出費を抑えることが出来ます。デフォルトは100にしています。
**▽temperature(任意)**
0 ~ 2 の間で設定し、高い値ほど出力をよりランダムにします。デフォルトは 1 で、それより低いと出力される文章は固定になっていき、高いと出力される単語にばらつきが出てきます。2 に近づいていくほど意味が通らない文章になります。
**▽top_p(任意)**
単語取得の範囲を設定します。値の範囲は 0 ～ 1 の間です。例えば 0.1 だと上位 10 %の確率を構成するトークンのみが考慮されるため、多様性がなくなっていきます。また、top_p と temperature の重複設定は推奨されていないため、ラジオボタンでどちらか一方を設定するようにしています。デフォルトは 1 です。
**▽presence_penalty(任意)**
一度使った単語に対して課すペナルティを設定し、ペナルティが大きいほど単語の出現確率が減ります。値の範囲は -2 ~ 2 で、デフォルトは 0 です。
**▽frequency_penalty(任意)**
ある単語の使った回数に対して課すペナルティを設定し、ペナルティが大きいほど単語の出現確率が減ります。値の範囲は -2 ~ 2 で、デフォルトは 0 です。presence_penalty と frequency_penaltyの重複設定は推奨されていないため、ラジオボタンでどちらか一方を設定するようにしています。

#### 会話画面
ChatGPTと会話できる画面になります。
メッセージを入力し送信ボタンを押下することで、LINEのようにチャットを楽しむことが出来ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822485/d9b2b71b-b810-5088-9afc-9c9d49c7de2a.png)

# アプリ完成までの流れ
1. プロジェクトのセットアップ
2. openAIのプラットフォームサイトからapi keyを取得する
3. 実装前の下準備
4. api keyを用いてAPIを実行する関数を作成する
5. layout作成
6. header作成
7. ホーム画面作成
8. prompt入力画面作成
9. 会話画面作成

# １．プロジェクトのセットアップ
まずはnext.jsのプロジェクトを作成します。
```bash:bash
npx create-next-app@latest chat-app
```
上記コマンドを叩くといくつか選択肢が出ますが、すべてデフォルトで問題ないです。
（私はsrcディレクトリが欲しかったのでそこだけ Yes に変更しています）
```bash:bash
√ Would you like to use TypeScript? ... No / 'Yes'
√ Would you like to use ESLint? ... No / 'Yes'
√ Would you like to use Tailwind CSS? ... No / 'Yes'
√ Would you like to use `src/` directory? ... No / 'Yes'
√ Would you like to use App Router? (recommended) ... No / 'Yes'
√ Would you like to customize the default import alias? ... 'No' / Yes
```
プロジェクトが作成されたら、必要なライブラリを諸々インストールします。
以下は`Chakra UI`のインストールですが、公式の手順通りに`Framer Motion`もインストールしています。
```bash:bash
npm i @chakra-ui/react @chakra-ui/next-js @emotion/react @emotion/styled framer-motion
```
続いて、`React Hook Form`をインストールします。
このライブラリにより、フォームのバリデーションチェック等を簡略化します。
```bash:bash
npm install react-hook-form
```
recoilをインストールします。
recoilは状態管理を楽にしてくれます。
```bash:bash
npm install recoil
```
sassをインストールします。
```bash:bash
npm install --save-dev sass
```
最後に`openai`をインストールします。
Chat GPTとのやり取りに必須のライブラリのため、今回の目玉です。
```bash:bash
npm install --save openai
```
以上でセットアップは完了です。

# ２．openAIのプラットフォームサイトからapi keyを取得する
以下の流れで取得します。
#### １．openAIのログイン画面に移動
[openAI](https://openai.com/)から右上の`Log in`を押下し、ログイン画面に遷移する。
#### ２．アカウント作成の上、ログインする
初回はアカウントの作成を行った上でログインします。
#### ３．クレジットカードの登録
体験版の範囲内であればクレジットカードの登録は不要です。
範囲外で使用する場合は、[Billing overview – OpenAI API](https://platform.openai.com/account/billing/overview) を開き、`Set up paid account`からクレジットカード登録していきます。
#### ４．API Keyの生成
左メニューの`API keys`を押下し、`Create new secret key`からAPI keyを生成します。
生成されたkeyは実装で利用しますが、あとで再確認することができないので必ずどこかにメモをしてください。
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822485/a10908f1-2009-c38a-1471-462fcf42414b.png)

# ３．実装前の下準備
#### 環境設定ファイル（.env.local）を作成して、api keyを記載する
プロジェクト直下に環境設定ファイルを配置します。
記載する内容は、先ほど生成したAPI keyになります。
```typescript:.env.local
OPENAI_API_KEY=手順2で生成したkeyをセット
```
#### constantsフォルダに定数を記載
デフォルトのpromptと、ヘッダーに表示させるサイトタイトルを定義します。
promptで指定した振る舞いを、chat GPTがしてくれるようになります。
```typescript:src/constants/constants.ts
export const defaultPrompt = '仲の良い友人のように会話してください。';
export const siteTitle = 'ChatGPTとおしゃべり';
```
#### atomを使える状態にする（providerの作成）
RecoilRootでアプリを囲うことで、アプリ内でrecoilを利用できるようになります。
以下のAppProviderコンポーネントは、後ほどLayoutで使用します。
```react:src/app/provider.tsx
'use client';

import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';

export default function AppProvider({ children }: { children: ReactNode }) {
  return <RecoilRoot>{children}</RecoilRoot>;
}
```

# ４．api keyを用いてAPIを実行する関数を作成する
API実行用の関数を作成します。
`apiKey`には、環境設定ファイルのKeyをセットします。
```typescript:src/pages/api/messages/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { Configuration, OpenAIApi } from 'openai';

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;
  }

  const message = req.body.message;
  const otherOption = req.body.otherOption;

  try {
    const completion = await openai.createChatCompletion({
      model: 'gpt-3.5-turbo',
      messages: message,
      ...otherOption,
    });
    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.',
        },
      });
    }
  }
}
```
# ５．layout作成
layoutを作成します。
先ほど作成したAppProviderコンポーネントを、アプリ全体にかかるように記述します。
```react:src/app/layout.tsx
import '@/assets/globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import AppProvider from './provider';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'chatgpt-api practice',
  description: 'chat gpt api を利用した練習用アプリケーション',
};

export default function RootLayout({
  children,
  header,
}: {
  children: React.ReactNode;
  header: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={`${inter.className} pt-[100px]`}>
        <AppProvider>
          {header}
          <main className="flex flex-grow items-center justify-center">{children}</main>
        </AppProvider>
      </body>
    </html>
  );
}
```
# ６．header作成
header用のコンポーネントを作成します。
フォルダに@がついているのはnext13で新たに導入された`Parallel Routes`を利用しています。
詳しくは[公式の説明](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes)をどうぞ。
※cssは別ファイルに記述していますが、ただのレイアウトなので省略しています。
```react:src/app/@header/page.tsx
import { siteTitle } from '@/constants/constants';
import styles from './page.module.scss';

const Page = () => {
  return (
    <header className={styles.container}>
      <h1 className={styles.title}>{siteTitle}</h1>
    </header>
  );
};

export default Page;
```
defaultファイルがないと、リロードしたときに404エラーとなってしまったので用意します。
```typescript:src/app/@header/default.tsx
import Header from './page';
export default function Default() {
  return <Header />;
}
```
# ７．ホーム画面作成
いよいよ画面作成です。
ディレクトリ名に()がついているのは、next13の[Route Groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups)を利用しています。
```react:src/app/(home)/page.tsx
import styles from './page.module.scss';
import Link from 'next/link';

export default function Page() {
  return (
    <div className={styles.container}>
      <h1>会話を開始しよう！</h1>
      <div className="mt-[30px]">
        <Link href="/setBehavior" className={styles.buttonContainer}>
          prompt入力画面へ
        </Link>
      </div>
    </div>
  );
}
```
# ８．prompt入力画面作成
この画面では、入力された値をrecoilにセットしています。
また、入力値のバリデーションチェックにはreact-hook-formを利用しています。
```react:src/app/setBehavior/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import styles from './page.module.scss';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { SetterOrUpdater, useSetRecoilState } from 'recoil';
import {
  frequencyPenaltyAtom,
  maxTokensAtom,
  presencePenaltyAtom,
  promptMsgAtom,
  temperatureAtom,
  topPAtom,
} from '@/atom';
import { useState } from 'react';

const radioList1 = ['temperature', 'top_p'];
const radioList2 = ['presence_penalty', 'frequency_penalty'];

const Page = () => {
  const router = useRouter();
  const [showDetailSetting, setShowDetailSetting] = useState(false);
  const setFrequencyPenalty: SetterOrUpdater<number> = useSetRecoilState(frequencyPenaltyAtom);
  const setMaxTokens: SetterOrUpdater<number> = useSetRecoilState(maxTokensAtom);
  const setPresencePenalty: SetterOrUpdater<number> = useSetRecoilState(presencePenaltyAtom);
  const setPromptMsg: SetterOrUpdater<string> = useSetRecoilState(promptMsgAtom);
  const setTemperature: SetterOrUpdater<number> = useSetRecoilState(temperatureAtom);
  const setTopP: SetterOrUpdater<number> = useSetRecoilState(topPAtom);
  const {
    register,
    handleSubmit,
    formState: { isValid, errors },
    getValues,
    watch,
  } = useForm({
    defaultValues: {
      prompt: '',
      max_tokens: '',
      probabilityParameterRadio: 0,
      temperature: '',
      top_p: '',
      penaltyRadio: 0,
      presence_penalty: '',
      frequency_penalty: '',
    },
    criteriaMode: 'all',
    mode: 'onChange',
  });

  const setOption = () => {
    setPromptMsg(getValues('prompt'));
    !!getValues('max_tokens') && setMaxTokens(Number(getValues('max_tokens')));
    getValues('probabilityParameterRadio') == 0
      ? !!getValues('temperature') && setTemperature(Number(getValues('temperature')))
      : !!getValues('top_p') && setTopP(Number(getValues('top_p')));
    getValues('penaltyRadio') == 0
      ? !!getValues('presence_penalty') && setPresencePenalty(Number(getValues('presence_penalty')))
      : !!getValues('frequency_penalty') &&
        setFrequencyPenalty(Number(getValues('frequency_penalty')));
  };

  return (
    <div className={styles.container}>
      <h1 className={styles.title}>プロンプト入力画面</h1>
      <form className={styles.formContainer} onSubmit={handleSubmit(() => {})}>
        <div className={styles.prompt}>
          <input
            id="prompt"
            className={styles.promptInput}
            placeholder=" "
            {...register('prompt', {
              required: {
                value: true,
                message: '入力必須です！',
              },
              maxLength: {
                value: 50,
                message: '50文字以内で入力してください！',
              },
            })}
          />
          <label htmlFor="prompt" className={styles.promptLabel}>
            promptを入力
          </label>
        </div>

        {errors.prompt?.types?.required && (
          <div className={styles.errorMsg}>{errors.prompt.types.required}</div>
        )}
        {errors.prompt?.types?.maxLength && (
          <div className={styles.errorMsg}>{errors.prompt.types.maxLength}</div>
        )}

        {showDetailSetting ? (
          <div className={styles.detailSettingContainer}>
            <div className="w-full py-[30px] px-[10px]">
              <div className={styles.detailSettingInputContainer}>
                <label htmlFor="max_tokens" className="mb-[5px] ml-[5px]">
                  max_tokens
                </label>
                <input
                  id="max_tokens"
                  className={styles.detailSettingInput}
                  placeholder="100"
                  {...register('max_tokens', {})}
                />
              </div>
              <div className="mt-[20px]">
                <div className="ml-[5px] mb-[5px]">
                  {radioList1.map((label: any, index) => (
                    <span key={label}>
                      <input
                        id={label}
                        type="radio"
                        value={index}
                        {...register('probabilityParameterRadio')}
                        defaultChecked={index === 0}
                      />
                      <label htmlFor={label} className="mr-[10px]">
                        {label}
                      </label>
                    </span>
                  ))}
                </div>
                {watch('probabilityParameterRadio') == 0 ? (
                  <div className={styles.detailSettingInputContainer}>
                    <input
                      className={styles.detailSettingInput}
                      placeholder="temperature"
                      {...register('temperature', {})}
                    />
                  </div>
                ) : (
                  <div className={styles.detailSettingInputContainer}>
                    <input
                      className={styles.detailSettingInput}
                      placeholder="top_p"
                      {...register('top_p', {})}
                    />
                  </div>
                )}
              </div>
              <div className="mt-[20px]">
                <div className="ml-[5px] mb-[5px]">
                  {radioList2.map((label, index) => (
                    <span key={label}>
                      <input
                        id={label}
                        type="radio"
                        value={index}
                        {...register('penaltyRadio')}
                        defaultChecked={index === 0}
                      />
                      <label htmlFor={label} className="mr-[10px]">
                        {label}
                      </label>
                    </span>
                  ))}
                </div>
                {watch('penaltyRadio') == 0 ? (
                  <div className={styles.detailSettingInputContainer}>
                    <input
                      className={styles.detailSettingInput}
                      placeholder="presence_penalty"
                      {...register('presence_penalty', {})}
                    />
                  </div>
                ) : (
                  <div className={styles.detailSettingInputContainer}>
                    <input
                      className={styles.detailSettingInput}
                      placeholder="frequency_penalty"
                      {...register('frequency_penalty', {})}
                    />
                  </div>
                )}
              </div>
            </div>
            <button className="mb-[20px]" onClick={() => setShowDetailSetting(false)}>
              詳細設定を閉じる
            </button>
          </div>
        ) : (
          <button className="mt-[30px]" onClick={() => setShowDetailSetting(true)}>
            詳細設定を開く
          </button>
        )}

        {isValid ? (
          <Link href="/chat" className={styles.startBtn} onClick={setOption}>
            start!
          </Link>
        ) : (
          <button type="submit" className={styles.startBtn}>
            start!
          </button>
        )}
      </form>
      <button className="mt-[20px]" onClick={() => router.back()}>
        戻る
      </button>
    </div>
  );
};

export default Page;
```
# ９．会話画面作成
会話画面では、prompt入力画面でセットした値をrecoilから取得し、APIのリクエストパラメータに渡してchat GPTとやりとりを行います。
会話用の各コンポーネントはcomponentsフォルダに実装しているので、詳しくはGit Hubからご覧ください。
```react:src/app/chat/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 { Message } from './types/custom';
import { useRecoilValue } from 'recoil';
import {
  frequencyPenaltyAtom,
  maxTokensAtom,
  presencePenaltyAtom,
  promptMsgAtom,
  temperatureAtom,
  topPAtom,
} from '@/atom';
import { defaultPrompt } from '@/constants/constants';
import Chat from '@/components/chatBody';
import ThreeDotsLoader from '@/components/loader/ThreeDotsLoader';
import InputForm from '@/components/inputForm/InputForm';

const Home: NextPage = () => {
  const frequency_penalty: number = useRecoilValue(frequencyPenaltyAtom);
  const max_tokens: number = useRecoilValue(maxTokensAtom);
  const presence_penalty: number = useRecoilValue(presencePenaltyAtom);
  const promptMsg: string = useRecoilValue(promptMsgAtom);
  const temperature: number = useRecoilValue(temperatureAtom);
  const top_p: number = useRecoilValue(topPAtom);
  const [chats, setChats] = useState<Message[]>([
    {
      role: 'system',
      content: promptMsg || defaultPrompt,
    },
  ]);
  const [isSubmitting, setIsSubmitting] = useState(false);

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

      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,
          })),
          otherOption: {
            frequency_penalty,
            max_tokens,
            presence_penalty,
            temperature,
            top_p,
          },
        }),
      });

      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>
        {isSubmitting && (
          <Flex alignSelf="flex-start" px="2rem" py="0.5rem">
            <ThreeDotsLoader />
          </Flex>
        )}
      </div>
      <InputForm onSubmit={handleSubmit} />
    </div>
  );
};

export default Home;
```
以上で、ブラウザ上でchat GPT APIのオプションを指定して会話するまでの流れが実装できました。

# おわりに
いかがだったでしょうか。
APIのリクエストパラメータは少し癖有でしたが、簡単にチャットアプリを作成できたかと思います。
AIは今も恐ろしいスピードで進化しているのでついていくのもやっとですが、
時代の流れに置いて行かれないようキャッチアップしていきたいですね。