概要
学習目的で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を返すという書き方を思いつくまで時間がかかりました。
ここの書き方を思いついて、うまく動作したときは非常にうれしかったです!
これからも日々精進していきます。