概要
以前、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の仕組み自体に対する理解が深まったと思うので、勉強になりました。