こんにちは、エンジニアの仁木です。
最近開発をする上で、わからないことを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を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
. ├── .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
でスキーマ定義します
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
1 2 3 4 5 6 7 |
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
1 2 3 4 5 6 7 8 9 10 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
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のリクエスト部分
1 2 3 4 5 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
'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 |
1 2 3 4 |
const [messages, setMessages] = useState<MessageHistories>([]) const [completion, setCompletion] = useState<string>('') const [question, setQuestion] = useState<string>("") const [isComposing, setIsComposing] = useState<boolean>(false) |
ユーザーが質問を送信するイベントです。
OpenAI APIへのリクエスト処理がメインです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
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:
が含まれるようになっているので、この文字列でデータを分割します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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
で変更があれば、回答内のコードのシンタックスハイライトを更新します。
1 2 3 |
useEffect(() => { hljs.highlightAll() }, [completion]) |
最後にページを作成します。
src/spp/caht-room/page.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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システムの開発は今後需要があると思うので、今後もノウハウを培っていきたいと思います。