💬

Nextjs ChatGPT APIで簡易的なチャットアプリを作る方法

2023/11/21に公開

環境

  • Next.js : 14.0.3
  • React : 18.0.0
  • OpenAI : 4.19.0
  • Recoil : 0.7.7
  • tailwind css : 3.3.0

概要

Next.jsとchatGPTのAPIを使用して、簡単な会話アプリを作成します。

今回は、会話の記録はStateで管理するため、リロードをすると会話の記録は画面上から消えます。

また、APIとのやりとりにはRoute Handlersを使用します。

手順

  1. 各インストール
    1. Next.jsアプリの作成
      1. npx create-next-app@latest chat-app
    2. OpenAIライブラリのインストール
      1. npm i openai
    3. Recoilのインストール
      1. npm install recoil
  2. 会話記録用のグローバルStateを作成
  3. UIの作成
    1. チャット画面の大枠
    2. チャットの吹き出し
    3. チャットの送信フォーム
  4. APIの作成
    1. OpenAIライブラリのインスタンスを作成する関数
    2. そのインスタンスを使用してOpenAIの**chat.completions.create**メソッドを呼ぶ関数
    3. Route Handlersを使用してAPIを叩く

作成方法

各インストールは上述の通り。

2. 会話記録用のグローバルStateを作成

  • チャットの会話を記録するためのStateをRecoilを使用して作成します。

/states/chatLogState.ts

// states/chatLogState.ts
import { atom } from 'recoil';

// メッセージオブジェクトの型を定義
interface Message {
  id: number;
  content: string;
  sender: string;
}

// gptResponseStateの型をMessageの配列として定義
export const chatLogState = atom<Message[]>({
  key: 'chatLogState',
  default: [
    { id: 1, content: "こんにちは!", sender: "user" },
    { id: 2, content: "元気ですか?", sender: "other" }
  ],
});

デフォルトでメッセージを2つ表示させておきます。

id はメッセージの一意な識別子、content はメッセージの内容、sender はメッセージの送信者を示します。

今回は、GPTからの返答はsender:”other”としておきます。

  • チャットのメッセージ(吹き出し)を表示するコンポーネント(<ChatMessage />)とメッセージの送信フォーム(<ChatForm />)でRecoilStateが使えるように<RecoilRoot></RecoilRoot>で囲みます。
'use client'

import React from 'react'
import { RecoilRoot, useRecoilState } from 'recoil'
import ChatMessage from './ChatMessage'
import ChatForm from './ChatForm'

const ChatClient = () => {
  return (
    <RecoilRoot >
      {/* <!-- メッセージエリア --> */}
      <div className="flex-grow overflow-auto p-6 space-y-5 ">
          <ChatMessage />
      </div>
  
      {/* <!-- テキスト入力フォーム --> */}
      <ChatForm />

    </RecoilRoot>
  )
}

export default ChatClient

<RecoilRoot>はクライアントコンポーネントでしか使えないので、’use client’でクライアントコンポーネントにします。

3. UIの作成

チャット画面の大枠

  • app/chat/page.tsx にチャット画面の大枠を作成します。
  • Recoilで作成したStateを使用するには、クライアントコンポーネントにする必要がりますが、大枠はサーバーコンポーネントにしたいため、Stateを使用するメッセージエリアとフォームを持つ<ChatClient />コンポーネントを別で作成し、Page.tsxで読み込みます。
import React from 'react'
import ChatClient from '../components/ChatClient';

async function ChatPage() {

  return (
    <div className="flex flex-col h-screen">
      {/* <!-- チャットヘッダー --> */}
      <div className="p-3 bg-gray-800 text-white">
        <h1 className="text-lg">チャットルーム</h1>
      </div>
      
      <ChatClient />
      
    </div>
  )
}

export default ChatPage

---
// app/components/ChatClient.tsx
'use client'

import React from 'react'
import { RecoilRoot, useRecoilState } from 'recoil'
import ChatMessage from './ChatMessage'
import ChatForm from './ChatForm'

const ChatClient = () => {
  return (
    <RecoilRoot >
      {/* <!-- メッセージエリア --> */}
      <div className="flex-grow overflow-auto p-6 space-y-5 ">
          <ChatMessage />
      </div>
  
      {/* <!-- テキスト入力エリア --> */}
      <ChatForm />

    </RecoilRoot>
  )
}

export default ChatClient

チャットの吹き出し

  • チャットのメッセージ(吹き出し)を作成します。
  • Stateで管理している配列の一つひとつがメッセージとなっているため、mapで繰り返し処理を行う
'use client'
import React from 'react'
import { useRecoilState } from 'recoil';
import { chatLogState } from '../states/chatLogState';

type MessageType = {
  id: number;
  content: string;
  sender: string;
};

const ChatMessage = () => {
  const [chatLog, setChatLog] = useRecoilState(chatLogState)
  return (
    <>
      {chatLog.map((message:MessageType) => {
        return (
          <div 
	    key={message.id} 
	    className={`flex items-end ${message.sender === 'user' ? 'justify-end' : ''}`}>
            {message.sender === 'other' && (
              <div className="flex-shrink-0 mr-2">
                <div className="h-8 w-8 bg-gray-300 rounded-full" /> {/* アイコンの代わり */}
              </div>
            )}
            <div 
	     className={`rounded p-2 ${message.sender === 'user' ? 'bg-blue-200' : 'bg-gray-500'}`}>
              <p className="text-sm">{message.content}</p>
            </div>
          </div>
        )
      })}
    </>
  )
}

export default ChatMessage
  • 三項演算子を使用して、senderuserotherの時で吹き出しの表示位置を変更します。
  • 同様に、senderotherの時のみ吹き出しの横にアイコンを表示させます。今回はアイコンの代わりにただの丸を表示します。

チャットの送信フォーム

  • メッセージを送信するためのフォームを作成します。
'use client'
import React, { useState } from 'react'
import { useRecoilState, useResetRecoilState } from 'recoil'
import { chatLogState } from '../states/chatLogState'

const ChatForm = () => {

  const [input, setInput] = useState<string>("")
  const [chatLog, setChatLog] = useRecoilState(chatLogState)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setInput("");
  };

  return (
    <form onSubmit={handleSubmit} className="p-3 bg-gray-200 flex justify-between items-center">
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        className="w-full p-2 mr-2 rounded focus:outline-none text-gray-800" 
        placeholder="メッセージを入力..."
      />
      <button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
        送信
      </button>
    </form>
  )
}

export default ChatForm
  • useStateでフォーム送信ボックス内のテキストを取得・管理します。
  • まだAPIは作成していないため、この段階ではボタンをクリックするとフォームのテキストが消えるだけです。

ここまでで、以下のような画面が出来上がっているはずです。

4. APIの作成

ここから、ChatGPTのAPIから返答をもらうためのAPIを作成していきます。

OpenAIライブラリのインスタンスを作成する関数を作成

  • まず /app/services/openai-service.ts に、APIにアクセスするためのインスタンスを作成する関数を用意します。
import OpenAI from "openai";

export const openaiClient = () => {
  return new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });
};
  • .env.localAPI KEYを設定しておき(OPEN_API_KEY=sk-********************* )、上記の関数で指定します。

OpenAIの**chat.completions.create**メソッドを呼ぶ関数を作成

  • 上記のインスタンスを使用して /app/services/openai-service.ts内に、ChatGPTにメッセージを投げる関数を作成します
import OpenAI from "openai";

export const openaiClient = () => {
  return new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });
};

export const sendPromptToGpt = async (prompt:string) => {
  const openai = openaiClient();

  const completion = await openai.chat.completions.create({
    model: "gpt-3.5-turbo",
    messages: [{ "role": "user", "content": prompt }],
    temperature: 1,
    max_tokens: 256,
    top_p: 1,
    frequency_penalty: 0,
    presence_penalty: 0,
  });

  const gptResponseMessage = completion.choices[0].message.content;
  return gptResponseMessage;
};
// 以下レスポンスの構造
// {
//   "choices": [
//     {
//       "finish_reason": "stop",
//       "index": 0,
//       "message": {
//         "content": "The 2020 World Series was played in Texas at Globe Life Field in Arlington.",
//         "role": "assistant"
//       }
//     }
//   ],
//   "created": 1677664795,
//   "id": "chatcmpl-7QyqpwdfhqwajicIEznoc6Q47XAyW",
//   "model": "gpt-3.5-turbo-0613",
//   "object": "chat.completion",
//   "usage": {
//     "completion_tokens": 17,
//     "prompt_tokens": 57,
//     "total_tokens": 74
//   }
// }
  • sendPromptToGpt関数では、引数としてprompt(=ユーザーからのメッセージ)を受け取ります。
  • 受け取ったpromptmessages: [{ "role": "user", "content": prompt }] のように、contentの値として設定します。
  • そのほかのmodeltemperatureなどはお好みです。
  • GPTからの返答は.choices[0].message.contentにあるので、それを取得しreturnします

Route Handlersを使用してAPIを叩く

最後に、Route Handlerで先ほど作った関数を使用できるようにAPIを作成します。

主な手順は2つです。

  1. app/api/response/route.ts に先ほど作った関数を呼び出すAPIを作成する
  2. フォーム送信イベントで、APIを呼び出し、送信したメッセージと返答をStateに追加する

  • app/api/response/route.ts に先ほど作った関数を呼び出すAPIを作成する
// app/api/response/route.ts

import { NextResponse } from 'next/server';
import { openaiClient, sendPromptToGpt } from "@/app/services/openai-service";

export async function POST(request: Request) {
  
  const openai = openaiClient();

  const { prompt } = await request.json();
  
  const gptResponseMessage = await sendPromptToGpt(prompt);
  const response = NextResponse.json({ gptResponseMessage })
  return response;
  }
  • POST関数を作成し、fetch(/api/response — method:POST ~~ ) で作動できるようにします。
  • openaiClient() で先ほど作成したOpenAIライブラリのインスタンスを作成します
  • requestで送られてくるユーザーからのメッセージを{ prompt } に代入します。
  • sendPromptToGpt関数を呼び出し、引数にpromptを指定します。
  • sendPromptToGpt関数からGPTからの返答がreturnされるので、それをgptResponseMessageに代入します。
  • responseに代入したJSONレスポンスをreturnします

  • フォーム送信イベントで、APIを呼び出し、送信したメッセージと返答をStateに追加する
'use client'
import React, { useState } from 'react'
import { useRecoilState, useResetRecoilState } from 'recoil'
import { chatLogState } from '../states/chatLogState'

const ChatForm = () => {

  const [input, setInput] = useState<string>("")
  const [chatLog, setChatLog] = useRecoilState(chatLogState)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const newId = chatLog.length > 0 ? chatLog[chatLog.length - 1].id + 1 : 1;

    const newUserMessage = { id: newId, content: input, sender: "user" };
    const updatedMessages = [...chatLog, newUserMessage];
    setChatLog(updatedMessages);
    setInput("");

    try {
      const res = await fetch(`/api/response`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json;charset=UTF-8",
        },
        body: JSON.stringify({ prompt: input }),
      });
      
      if (!res.ok) {
        throw new Error('Response error');
      }

      const result = await res.json();
      const newGptId = newId + 1;
      const newGptMessage = { id: newGptId, content: result.gptResponseMessage, sender: "other" };
      setChatLog([...updatedMessages, newGptMessage]);
    } catch (error) {
      console.error('Error fetching GPT response:', error);
    }
    
  };

  return (
    <form onSubmit={handleSubmit} className="p-3 bg-gray-200 flex justify-between items-center">
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        className="w-full p-2 mr-2 rounded focus:outline-none text-gray-800" 
        placeholder="メッセージを入力..."
      />
      <button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
        送信
      </button>
    </form>
  )
}

export default ChatForm
  • handleSubmit関数の中身を作成していきます。
  • まず、ユーザーが送信する新規メッセージの処理を記述します。
    • これまでのメッセージが格納されているchatLogから、全部のメッセージ数をカウント・取得し、その数より+1した値をnewIdとします。
    • idにnewIdを指定し、contentに現在フォームに入力されている値(=input)を指定し、senderuserとして、ユーザーの新規メッセージを作成します。
    • これまでのメッセージをスプレッド構文で展開し、ユーザーの新規メッセージを追加した新しい配列を定義します。
    • setChatLog(新しい配列) でchatLogを更新します。
    • フォームに表示されているテキストは不要なため、ここで画面から削除します
  • 次に、ChatGPTからの返答を獲得するためのAPIを叩きます
    • 先ほどapp/api/response/route.ts に作成したPOST関数を使って、ChatGPTにメッセージを送信します
    • fetchの引数に/api/responsemethod, headers,bodyを指定してリクエストします。
    • bodyには、フォーム内のテキスト(=input)をpromptとして渡します。
    • res.json()を使用して、レスポンスのボディからJSONデータを取得します。ここに、ChatGPTからの返答が入っています。(POST関数でconst response = NextResponse.json({ gptResponseMessage })をしている)
    • 先ほど作成したユーザーの新規メッセージに+1した値をnewGptId に代入します。
    • idにnewGptIdを指定し、contentにChatGPTからの返答(=result.gptResponseMessage)を指定し、senderotherとして、ChatGPTの返答メッセージを作成します。
    • これまでのメッセージをスプレッド構文で展開し、ChatGPTの返答メッセージを追加した新しい配列を定義し、chatLogを更新します。

以上で、チャットの記録をStateで管理する簡単なチャットアプリを作成することができました。

Discussion