NextJS(App Router)を使ってChatGPTとチャットできるアプリを作成してみた

Next.jsのロゴ プログラミング

概要

学習目的でChat GPTのAPIを使ってAIとチャットができるアプリを作ってみました。
Next.jsのRoute Handlersを使用して、APIサーバを簡単に構築しました。

動作する様子は以下の通りです。

リポジトリは以下です。

GitHub - nakajima97/chat-gpt-chat-sample: NextJS単体でChatGPTとチャットできるアプリ
NextJS単体でChatGPTとチャットできるアプリ. Contribute to nakajima97/chat-gpt-chat-sample development by creating an account on GitHub.

環境

  • Next.js v14.2.4
  • App Router
  • openai 4.55.4

やってみた

サーバ側

import { NextRequest, NextResponse } from 'next/server';
import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY
});

export const POST = async (request: NextRequest) => {
  const messages = await request.json();

  // completion: 完了、完成
  const completions = await openai.chat.completions.create({
    model: 'gpt-4o-2024-05-13',
    messages,
    stream: true
  })

  // レスポンスのデータを読み取るためのreaderを作成
  const reader = completions.toReadableStream().getReader();
  const decoder = new TextDecoder('utf-8');

  // データを返すための変数を作成
  const stream = new TransformStream();
  const writer = stream.writable.getWriter();
  const encoder = new TextEncoder();

  // 先にレスポンスを返すために即時関数で非同期実行する
  (async () => {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        break;
      }

      const decodedValue = decoder.decode(value);
      const response = encoder.encode(`data: ${decodedValue}\n\n`);
      writer.write(response)
    }

    writer.close();
  })();

  return new NextResponse(stream.readable, {
    status: 200,
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
      "Content-Encoding": "none"
    }
  });
}

ポイントは writer.write(response) を非同期で実行しつつこの処理自体はawaitで待たないことです。
ここでawait使ったり、(async () => {…}) を使わずに実行するとChatGPTの回答を全部受け取ってからクライアントに返す動きをします。

回答内容をリアルタイムで反映したいため、このままでは期待通りに動作しません。
writer.write(response) を実行する前に NextResponse を返すことでSSEでデータを随時クライアントに送ることができ、クライアント側もヘッダーからSSEでデータを受け取っていることを認識できるようになります。

クライアント側

'use client';

import { ChatForm } from "@/components/ui/ChatForm";
import { ChatMain } from "@/components/ui/ChatMain";
import { Header } from "@/components/ui/Header";
import { ChatHistories } from "@/types";
import { Box } from "@mui/material";
import React, { useState } from "react";

export const Chat = () => {
  // 会話履歴を管理するためのstate
  const [chatHistories, setChatHistories] = useState<ChatHistories>([]);

  // チャット入力欄の値を管理するためのstate
  const [message, setMessage] = useState("");

  const sendMessage = async () => {
    if (message === "") return;

    const newChatHistories: ChatHistories = [
      ...chatHistories,
      {
        id: chatHistories[chatHistories.length - 1]?.id + 1 || 0,
        content: message,
        role: "user",
      },
    ]

    // 会話履歴の更新
    setChatHistories(newChatHistories);

    // APIを叩いてbotの返答を取得
    const response = await fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify(newChatHistories),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder("utf-8");

    if (!reader) return false;

    setChatHistories((prev) => [
      ...prev,
      {
        id: prev[prev.length - 1]?.id + 1 || 0,
        content: "",
        role: "assistant",
      },
    ]);

    while(true) {
      const { done, value } = await reader.read();

      if (done) break;
      if (!value) continue;

      const decodedValue = decoder.decode(value);

      const lines = decodedValue.split("data: ")
        .map((line) => line.trim())
        .filter((line) => line !== "");

      for (const line of lines) {
        try {
          const chunk = JSON.parse(line);
          const text = chunk.choices[0].delta.content || '';

          setChatHistories((prev) => (
            prev.map((chatHistory, index) => {
              const lastIndex = prev.length - 1;
              if (index === lastIndex) {
                return {
                  id: chatHistory.id,
                  content: chatHistory.content + text,
                  role: "assistant",
                };
              }
              return chatHistory
            }
          )));
        } catch (error) {
          console.error(error);
        }
      }
    }

    // チャット入力欄の初期化
    setMessage("");
  };

  return (
    <Box
      component="main"
      sx={{
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        height: "100dvh",
        width: "100vhw",
      }}
    >
      <Box sx={{ width: "100%" }}>
        <Header />
      </Box>
      <Box sx={{ flexGrow: 1, width: "100%" }}>
        <ChatMain chatHistories={chatHistories}/>
      </Box>
      <Box sx={{ width: "100%" }}>
        <ChatForm message={message} setMessage={setMessage} sendMessage={sendMessage}/>
      </Box>
    </Box>
  );
};

クライアント側のコードに関しては特段説明することはないかと思います。
会話履歴を管理するstateの末尾に空のデータを追加して、受け取った回答を随時そこに追記していく感じです。

まとめ

苦戦したのはAPI側のSSEでデータを渡す処理です。
非同期関数を使って先にreturnでstreamを返すという書き方を思いつくまで時間がかかりました。
ここの書き方を思いついて、うまく動作したときは非常にうれしかったです!

これからも日々精進していきます。

Follow me!

PAGE TOP
タイトルとURLをコピーしました