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

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

概要

以前、App RouterでChatGPTとチャットできるアプリを開発した記事を上げたのですが、今回はそれのPage Router版です。
クライアントサイドのコードは変わらないのですが、サーバサイドのコードが大きく変わっています。

GitHub - nakajima97/next-page-router-chat-gpt-sample
Contribute to nakajima97/next-page-router-chat-gpt-sample development by creating an account on GitHub.

環境

  • Next.js v14.2.5
  • Page Router
  • openai 14.2.5

やってみた

サーバ側

import type { NextApiRequest, NextApiResponse } from 'next';
import OpenAI from 'openai';
import type { ChatCompletionMessageParam } from 'openai/src/resources/index.js';

const configuration = {
	apiKey: process.env.OPEN_AI_API_KEY,
};

const openAi = new OpenAI(configuration);

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
	if (req.method !== 'POST') {
		return res.status(405).end();
	}

	let messages: ChatCompletionMessageParam[] = [];
	try {
		({ messages } = req.body);
	} catch (error) {
		return res.status(422).json({ message: 'Invalid message' });
	}

	if (!messages || messages.length === 0) {
		return res.status(422).json({ message: 'Invalid message' });
	}

	// completions = 完成、完了、終了
	const completions = await openAi.chat.completions.create({
		messages,
		model: 'gpt-4o-mini',
		stream: true,
	});

	// ChatGPTからのstreamを読み取る
	const reader = completions.toReadableStream().getReader();

	// レスポンスをデコードするためのTextDecoderを作成
	const decoder = new TextDecoder('utf-8');

	// SSEであることを明示
	res.setHeader('Content-Type', 'text/event-stream');
	res.setHeader('Cache-Control', 'no-cache');
	res.setHeader('Connection', 'keep-alive');
	res.setHeader('Content-Encoding', 'none');

	// レスポンスを返す
	res.writeHead(200);

	// ChatGPTからのレスポンスをクライアントに送信
	while (true) {
		const { done, value } = await reader.read();

		if (done) {
			break;
		}

		const decodedValue = decoder.decode(value);
		res.write(`data: ${decodedValue}\n\n`);
	}

	// レスポンスを閉じる
	res.end();
};

export default handler;

ポイントとしてはヘッダーを設定したら res.writeHead(200) でレスポンスを返すことです。
ここの書き方がApp Routerと比べた際に大きく変わる部分です。
ほかはApp Routerと一緒でChatGPT APIたたいた結果のSSEをそのまま読み込んで
都度クライアントに返すだけです。

クライアント側

// メッセージ送信とChatGPTからのレスポンスを表示する
const handleSendMessage = async () => {
  const sendMessage = message.trim();

  if (!sendMessage) {
    return;
  }

  setChatHistory((prevChatHistory) => [
    ...prevChatHistory,
    { role: 'user', content: sendMessage, id: Date.now().toString() },
  ]);

  const messages = [...chatHistory, { role: 'user', content: sendMessage }];

  setMessage('');

  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ messages }),
  });

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

  if (!reader) {
    console.error('Failed to get reader');
    return false;
  }

  setChatHistory((prevChatHistory) => [
    ...prevChatHistory,
    { role: 'assistant', content: '', id: Date.now().toString() },
  ]);

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

    const lines = decoder.decode(value);
    const jsons = lines
      .split('data: ') // 各行は data: というキーワードで始まる
      .map((line) => line.trim())
      .filter((s) => s); // 余計な空行を取り除く
    for (const json of jsons) {
      try {
        if (json === '[DONE]') {
          return; // 終端記号
        }
        const chunk = JSON.parse(json);
        const text = chunk.choices[0].delta.content || '';
        // textの値をchatHistoryに追加
        // 新しいメッセージをチャット履歴に追加
        setChatHistory((prevChat) =>
          prevChat.map((chat, index) => {
            const lastChatIndex = prevChat.length - 1;
            if (index === lastChatIndex) {
              return {
                ...chat,
                content: prevChat[lastChatIndex].content + text,
              };
            }
            return chat;
          }),
        );
      } catch (error) {
        console.error(error);
      }
    }
  }
};

まとめ

Page RouterとApp RouterでAPIのコードがガラッと変わることに驚きました。
同じ動きをするコードを別の書き方で書くことでSSEの仕組み自体に対する理解が深まったと思うので、勉強になりました。

Follow me!

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