こんにちは、エンジニアの仁木です。
最近開発をする上で、わからないことをAIに聞くのが当たり前になりました。
2~3ヶ月前くらいまでは、癖でまずはGoogleで調べていたものですが、今ではまずはAIに聞くということの方が増えてきました。
基本的に自分はコーディングしている時にAIに質問するので、エディターの拡張機能を利用します。
わからないことを聞く分にはエディターの拡張で十分で手軽なのですが、回答内容を後から読み直したりできるようにしたいと思い、簡単なAIチャットのWebアプリケーションを作り始めました。
色々作ってみたい機能はあるのですが、まずは基本となるチャットを作ってみたので、今回はチャットを実装した方法の紹介をしたいと思います。
細かく書くと主題がボヤケてしまうので、チャット機能の実装部分にフォーカスし、直接関係のない部分は省いて書いてみます。
今回は以下の環境を用意しました。
Next.jsはTypescriptで書いていきます。
CSSはCSS Module + SCSSを使います。
最初はTailwind CSSに挑戦しようとしたのですが、使い方を調べるのにあまり時間をかけたくなかったので、自分で書いていくSCSSスタイルに切り替えました。
データベースのやり取りはORMのprismaを使います。
余談ですが、prismaはマイグレーション、データベースのGUI、スキーマ定義したモデルの型生成など、ライブラリ1つでデータベースまわりの環境を整えられるのがお手軽で良いです。
型チェックやバリデーションにはzodを使います。
ChatGPTは、OpenAI APIを使うため、APIキーを発行しています。
以下のようなチャット画面を作ります。

一般的なチャットのUIです。
LINEのようにユーザーとAIのメッセージを左右に分けて表示します。
メッセージ下部に入力フィールドを用意し、質問を入力/送信します。
AIの回答はマークダウンで返ってくるので、HTMLにパースして画面に表示させます。
また、AIの回答は全文を待ってから表示すると時間がかかるので、APIのレスポンスはストリーミングさせてリアルタイムで回答を反映します。
コードはシンタックスハイライトさせて可読性を良くします。
Next.jsのファイル構成です。
今回はApp Routerを使います。
. ├── .env ├── .env.local ├── README.md ├── next-env.d.ts ├── next.config.mjs ├── node_modules ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma │ ├── dev.db │ ├── migrations │ └── schema.prisma ├── public │ ├── next.svg │ └── vercel.svg ├── src │ ├── app │ ├── components │ ├── features │ └── styles └── tsconfig.json
AIの回答やユーザーの質問など、やり取りのメッセージはデータベースに保存するのでメッセージのテーブルを作成します。
本来はメッセージ毎にチャットグループやユーザーを紐づけますが、今回はメッセージの保存のみの設計で考えます。
必要な項目は、ID、ロール、メッセージのテキスト、メッセージ送信日時です。
ロールはそのメッセージがAIのものなのかユーザーのものなのかを識別する項目です。
prisma/schema.prismaでスキーマ定義します
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Message {
id Int @id @default(autoincrement())
role String
message String
created_at DateTime @default(now())
}
スキーマ定義後、npx prisma migrate dev --name message でテーブルを作成します。
テーブルを作成したので、メッセージを保存する処理を書いていきます。
メッセージに関する機能はsrc/feature/messageに実装していきます。
DBの操作はprisma clientを使います。
src/feature/message/services/create/index.ts
import prisma from '@/features/db/client';
import { requestSchema, RequestSchemaType } from "./schema";
import { Message } from '@prisma/client';
const createMessage = async (parameters: RequestSchemaType): Promise<Message> => {
const validated = requestSchema.parse(parameters)
const message = await prisma.message.create({
data: {
role: validated.role,
message: validated.message
}
})
return message
}
export default createMessage
登録パラメータのバリデーションルールを定義します。
src/feature/message/services/create/schema.ts
import { z } from 'zod'
export const requestSchema = z.object({
role: z.string(),
message: z.string(),
}).required()
export type RequestSchemaType = z.infer<typeof requestSchema>
クライアントからのメッセージ保存のリクエストはAPIルーティング http://localhost/api/message/createを経由させます。
リクエストはPOSTで受け付けます。
src/app/api/message/create/route.ts
import { NextRequest, NextResponse } from "next/server"
import create from '@/features/message/services/create'
import { Message } from "@prisma/client"
export async function POST(request: NextRequest): Promise<NextResponse<Message>>
{
const message = await create(await request.json())
return NextResponse.json(message)
}
ちなみに、↑のルーティングのファイルに直接 src/features/message/services/create/index.ts の保存処理を書いても良いのですが、そうするとサーバーコンポーネント上の保存処理を別で書かないといけなくなるため、このように処理を分けています。
また、APIのルーティングは自身のアプリケーション内だけで利用するので本来はルーティング自体は不要です。
リクエストの処理はServer Actionsで実装するとより良いかもしれません。
OpenAI APIは completion を使います。
completionはAIに質問を送り、回答を取得するAPIです。
リクエストは過去の履歴も含めて送ることが可能で、やり取りの文脈を踏まえた回答を得ることができます。
src/app/api/openai/completion/route.ts
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai"
const chat = async(
message: any,
onStreamChunk: (message: string) => void,
onStreamEnd: () => void
) => {
const Ai = new OpenAI({
apiKey: process.env.OPEN_AI_API_KEY ?? ''
})
const completionStream = await Ai.chat.completions.create({
messages: message,
model: "gpt-4o-mini",
stream: true
})
for await (const chunk of completionStream) {
onStreamChunk(chunk.choices[0]?.delta?.content || "")
}
onStreamEnd()
}
export async function POST(request: NextRequest)
{
const stream = new TransformStream()
const writer = stream.writable.getWriter()
chat(
await request.json(),
(message) => writer.write(`data:${message}`),
() => writer.close()
)
return new NextResponse(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache, no'
}
})
}
APIへのリクエストは公式が用意しているライブラリを利用します。
APIキーの環境変数OPEN_AI_API_KEYは.env.localで定義します。
APIのリクエスト部分
const completionStream = await Ai.chat.completions.create({
messages: message,
model: "gpt-4o-mini",
stream: true
})
messages には履歴も含めたメッセージのオブジェクトを渡します。
使用するモデルはmodelで指定します。
また、AIの回答はストリーミングで取得したいので、stream: trueを指定します。
stream形式で取得したデータは、そのままクライアントのリクエストにもストリーミング形式で返す必要があります。
今回は Server-Send Event(SSE)という仕組みを使って実現します。
SSEについての詳細は以下の記事が参考になると思います。
MDN: サーバー送信イベントの使用
https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events/Using_server-sent_events
ChatGPTをぬるぬるにする🐌Server-Sent Eventsの基礎知識
https://zenn.dev/sekapi/articles/a089c203adad74
注意しないといけないのが、SSEはGETのみ対応しており、POSTなどでリクエストボディを含めたリクエストはできません。
そのため、クライアント側の実装は少し工夫が必要になります。
クライアント側の実装は以下のコンポーネント作成のセクションに記載します。
チャットのUIコンポーネントです。
まずはコンポーネントのコードの全文です。
src/features/message/components/Chat/index.tsx
'use client'
import { useState, useEffect, KeyboardEvent } from "react"
import clsx from "clsx"
import { marked } from "marked"
import hljs from "highlight.js"
import 'highlight.js/styles/github.css';
import style from './style.module.scss'
import Textarea01 from "@/components/Form/Textarea/Textarea01"
type MessageHistories = {
role: string,
content: string
}[]
const Chat = ({messages}) => {
const [messages, setMessages] = useState<MessageHistories>(messages ?? [])
const [completion, setCompletion] = useState<string>('')
const [question, setQuestion] = useState<string>("")
const [isComposing, setIsComposing] = useState<boolean>(false)
useEffect(() => {
hljs.highlightAll()
}, [completion])
const onQuestionHandler = async (event: KeyboardEvent<HTMLTextAreaElement>): Promise<void> => {
if (!isSubmitText(event, isComposing, question)) return
setQuestion(() => "")
const questionMessage = {role: "user", content: question}
// ユーザーの質問を追加
setMessages(previousMessages => [
...previousMessages,
questionMessage
])
const response = await fetch("/api/openai/completion", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([
...messages,
questionMessage
])
})
const reader = response.body?.getReader()
if (!reader) return
let answer = ""
let decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break
if (!value) continue;
const lines = decoder.decode(value, { stream: true});
// `data: `というキーワードで分割する
const jsons = lines.split('data:')
for (const json of jsons) {
try {
answer += json
setCompletion(prevCompletion => prevCompletion + json)
} catch (error) {
console.error(error);
}
}
}
// AIの回答を追加
setMessages(previousMessages => [
...previousMessages,
{role: "assistant", content: answer}
])
// 質問を保存
await fetch('/api/message/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
role: 'user',
message: question
})
})
// 回答を保存
await fetch('/api/message/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
role: 'assistant',
message: answer
})
})
setCompletion(() => "")
}
return (
<div className={clsx(style.root)}>
<div>
{/* メッセージ履歴 */}
<div className={clsx(style.history)}>
{messages.map((message, key) => (
<div key={key} className={clsx(style.historyItem, message.role === 'user' ? style.historyUserItem : style.historyAssistantItem)}>
<div dangerouslySetInnerHTML={{__html: marked.parse(message.content)}} className={clsx(style.historyContent, message.role === 'user' ? style.historyUserContent : style.historyAssistantContent)} />
</div>
))}
</div>
{/* AIの回答 */}
{completion && (
<div dangerouslySetInnerHTML={{__html: marked.parse(completion)}} className={clsx(style.historyContent, style.historyAssistantContent)} />
)}
</div>
{/* ユーザーのメッセージ */}
<div className={clsx(style.question)}>
<Textarea01
name="question"
value={question.replace(/^[\n\r]$/, '')}
className={clsx(style.questionInput)}
onInput={e => setQuestion(e.currentTarget.value)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={onQuestionHandler} />
</div>
</div>
)
}
export default Chat
const [messages, setMessages] = useState<MessageHistories>([])
const [completion, setCompletion] = useState<string>('')
const [question, setQuestion] = useState<string>("")
const [isComposing, setIsComposing] = useState<boolean>(false)
ユーザーが質問を送信するイベントです。
OpenAI APIへのリクエスト処理がメインです。
const onQuestionHandler = async (event: KeyboardEvent<HTMLTextAreaElement>): Promise<void> => {
if (!isSubmitText(event, isComposing, question)) return
setQuestion(() => "")
const questionMessage = {role: "user", content: question}
// ユーザーの質問を追加
setMessages(previousMessages => [
...previousMessages,
questionMessage
])
const response = await fetch("/api/openai/completion", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([
{role: "system", content: instruction},
...messages,
questionMessage
])
})
const reader = response.body?.getReader()
if (!reader) return
let answer = ""
let decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break
if (!value) continue;
const lines = decoder.decode(value, { stream: true});
// `data: `というキーワードで分割する
const jsons = lines.split('data:')
for (const json of jsons) {
try {
answer += json
setCompletion(prevCompletion => prevCompletion + json)
} catch (error) {
console.error(error);
}
}
}
// AIの回答を追加
setMessages(previousMessages => [
...previousMessages,
{role: "assistant", content: answer}
])
// 質問を保存
await fetch('/api/message/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
role: 'user',
message: question
})
})
// 回答を保存
await fetch('/api/message/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
role: 'assistant',
message: answer
})
})
setCompletion(() => "")
}
fetch APIでhttp://localhost/api/openai/completionにリクエストします。
レスポンスはストリーミングで返ってくるので、レスポンスのBodyはgetReader()で戻り値を読み込むようにします。
データの読み込みはwhileのループ内で行い、完了フラグが渡されたタイミングでループから抜けるようにしています。
また、データは接頭辞にdata:が含まれるようになっているので、この文字列でデータを分割します。
const reader = response.body?.getReader()
if (!reader) return
let answer = ""
let decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break
if (!value) continue;
const lines = decoder.decode(value, { stream: true});
// `data: `というキーワードで分割する
const jsons = lines.split('data:')
for (const json of jsons) {
try {
answer += json
setCompletion(prevCompletion => prevCompletion + json)
} catch (error) {
console.error(error);
}
}
}
分割したデータを`setCompletion(prevCompletion => prevCompletion + json)` でAIの回答のステートcompletionにセットします。
completion のステートはuseEffectで変更があれば、回答内のコードのシンタックスハイライトを更新します。
useEffect(() => {
hljs.highlightAll()
}, [completion])
最後にページを作成します。
src/spp/caht-room/page.tsx
import clsx from "clsx"
import style from './style.module.scss'
import getMessages from "@/features/message/services/get"
import Chat from "@/features/message/components/Chat"
import { notFound } from "next/navigation"
export default async function ChatRoom()
{
const messages = await getMessages()
return (
<div className={clsx(style.root)}>
<Chat messages={messages} />
</div>
)
}
メッセージの取得処理 getMessagesは今回省いておりますが、メッセージの保存処理同様にsrc/features/message内でprisma clientを使って取得処理を実装します。
作ってみて思ったのが、シンプルな機能であれば思ったより簡潔に実装できることがわかりました。
Next.jsを始め、既存のフレームワークやライブラリを使うことで、肝心のアプリケーションのコアなロジック部分の実装に集中できるのは良いですね。
AIを組み込んだWebシステムの開発は今後需要があると思うので、今後もノウハウを培っていきたいと思います。