AURA 株式会社アウラ

RECRUIT急募!採用エントリー
AIチャットを作ろう~Webアプリケーション開発~

スタッフブログ

Staff Blog
  1. ホームページ制作はアウラ:ホーム
  2. スタッフブログ
  3. AIチャットを作ろう~Webアプリケーション開発~

AIチャットを作ろう~Webアプリケーション開発~

AIチャットを作ろう~Webアプリケーション開発~

こんにちは、エンジニアの仁木です。

最近開発をする上で、わからないことをAIに聞くのが当たり前になりました。
2~3ヶ月前くらいまでは、癖でまずはGoogleで調べていたものですが、今ではまずはAIに聞くということの方が増えてきました。

基本的に自分はコーディングしている時にAIに質問するので、エディターの拡張機能を利用します。
わからないことを聞く分にはエディターの拡張で十分で手軽なのですが、回答内容を後から読み直したりできるようにしたいと思い、簡単なAIチャットのWebアプリケーションを作り始めました。

色々作ってみたい機能はあるのですが、まずは基本となるチャットを作ってみたので、今回はチャットを実装した方法の紹介をしたいと思います。
細かく書くと主題がボヤケてしまうので、チャット機能の実装部分にフォーカスし、直接関係のない部分は省いて書いてみます。

開発環境

今回は以下の環境を用意しました。

  • Webサーバー … Node.js(v22)
  • バックエンド/フロントエンド … Next.js (v14)
  • データベース … SQLite

Next.jsはTypescriptで書いていきます。

ライブラリやAPI

CSSはCSS Module + SCSSを使います。
最初はTailwind CSSに挑戦しようとしたのですが、使い方を調べるのにあまり時間をかけたくなかったので、自分で書いていくSCSSスタイルに切り替えました。

データベースのやり取りはORMのprismaを使います。
余談ですが、prismaはマイグレーション、データベースのGUI、スキーマ定義したモデルの型生成など、ライブラリ1つでデータベースまわりの環境を整えられるのがお手軽で良いです。

型チェックやバリデーションにはzodを使います。

ChatGPTは、OpenAI APIを使うため、APIキーを発行しています。

チャットのUI

以下のようなチャット画面を作ります。

一般的なチャットの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ルーティングの作成

クライアントからのメッセージ保存のリクエストは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へのリクエスト

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

状態管理

  • messages … AIとユーザーのメッセージ履歴
  • completion … APIで取得したAIの回答
  • question … ユーザーが入力した質問テキスト
  • isComposing … 日本語入力時、漢字変換などでEnterキーが押されてもSubmitされないようにするフラグ
const [messages, setMessages] = useState<MessageHistories>([])
const [completion, setCompletion] = useState<string>('')
const [question, setQuestion] = useState<string>("")
const [isComposing, setIsComposing] = useState<boolean>(false)

質問を送信するイベント <onQuestionHandler>

ユーザーが質問を送信するイベントです。
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にリクエストします。
レスポンスはストリーミングで返ってくるので、レスポンスのBodygetReader()で戻り値を読み込むようにします。

データの読み込みは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システムの開発は今後需要があると思うので、今後もノウハウを培っていきたいと思います。

お電話でのお問い合わせはこちら:06-6292-8577。受付時間は平日9:30~18:30 インターネットからは24時間受付中!お問い合わせフォームはこちら
RECRUIT急募!採用エントリー