1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactとHono.jsでAIチャットアプリを作る

Last updated at Posted at 2025-02-08

0. はじめに

この記事では、AIチャットアプリを作成していきます!

最後まで読むと↓のようなアプリを作れるようになります 👏

Video to GIF Converter.gif

工夫したポイント

  • ストリーム形式でレスポンスを受け取る
    • ユーザ体験の向上 ✨

1. 技術スタック

1.1. Hono.js

バックエンド(API)を作成します。
最近耳にする機会が増えたのでこれを機に触ってみたかったので採用しました。
※フロント側で直接OpenAI APIを呼べばいいので、正直このチャットアプリにAPIは不要ですがHono.js使いたかったんです・・

ChatGPT曰く、以下のような特徴があります。

  • 軽量で高速: 最小限のコア機能を提供し、レスポンスが非常に高速であるため、高パフォーマンスなAPIを簡単に構築できます
  • シンプルなAPI: 直感的で使いやすいAPI設計により、少ないコードでWebアプリケーションやAPIを構築できます
  • クロスランタイム対応: Hono.jsは、Node.js、Deno、Bunといった複数のJavaScriptランタイムをサポートし、柔軟に選択できます
  • ミドルウェアサポート: ミドルウェア機能を簡単に追加でき、リクエストの処理やエラーハンドリングなどが簡単に実装できます

ランタイムには Bun を採用しました。これも個人的には初の試みです。

1.2. React

フロントページを作成します。
業務ではVue.jsを使うことが多いので、プライベートではReactを使うことが多いです。

1.3. OpenAI API

今回作るチャットアプリの要です。


2. 事前準備 - OpenAI APIキー発行

OpenAI APIを利用するためには、OpenAI API Keyを発行する必要があります。

OpenAI APIの利用には料金が発生します
料金体制はこちらをご確認ください

2.1. OpenAIアカウントを作成する

登録画面はこちら

2.2. 支払い情報を登録する

Settings > Billing から残高をチャージできるはずです。

最低5ドルからチャージ可能で、デフォルトで「Auto recharge is off」になっているので、チャージした金額以上請求が来ることはないはず(ちょっと不安)

スクリーンショット 2025-02-08 14.28.25.png

2.3. APIキーを発行する

Settings > API keys からAPIキーを作成します。
APIキーは一度しか表示されないためメモしておいてください。

スクリーンショット 2025-02-08 14.37.18.png


3. 構成

成果物がイメージしやすいように最終的な構成を示しておきます。
ほとんどのファイルが後述する bun initnpm create vite@latest コマンドで作成されたものです。

フロントエンドとバックエンドが同じリポジトリ内で管理されるモノリポジトリ構成にしてます。

ai_chat_app/
∟ backend/
  ∟ index.ts
  ∟ package.json
∟ frontend/
  ∟ src/
    ∟ App.tsx
    ∟ main.tsx
  ∟ index.html
  ∟ package.json

※説明に不要なファイルは省略してます


4. バックエンド( Hono.js )の実装

この章は /backend 配下で作業を行います。

# ルートディレクトリ作成(ここではプロジェクト名をai_chat_appとしてます)
mkdir ai_chat_app
cd ai_chat_app

# バックエンド用ディレクトリ作成
mkdir backend
cd backend

Bun を使うのでまだ入っていない方は下記コマンドでインストールします。

curl -fsSL https://bun.sh/install | bash

4.1. bun init

bun init

質問に答えていく

package name (backend): # パッケージ名の入力
entry point (index.ts): # エントリーポイントの入力

4.2. 必要なパッケージのインストール

# Hono.js
bun add hono

# OpenAI API
bun add openai

4.3. 【本題】 API実装

仕様

HTTPメソッド エンドポイント
POST /openai/stream/

リクエストパラメータ

パラメータ 必須 説明
message AIへの質問

理解が難しそうな部分にはコメント入れてます!

ts index.ts
import { Hono } from "hono";
import { OpenAI } from "openai";

const app = new Hono();

const openai = new OpenAI({
  apiKey: "ご自身のAPIキーを入れてください",
});

app.post("/openai/stream/", async (c) => {
  const { message } = await c.req.json();
  if (!message) {
    return c.json({ error: "message is required" }, 400);
  }

  // OpenAI APIへリクエスト
  const stream = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: message }],
    stream: true,
  });

  return new Response(
    // ストリーム作成
    new ReadableStream({
      async start(controller) {
        // OpenAI APIから返される stream のデータをチャンク(データ片)ごとに順番に処理
        for await (const chunk of stream) {
          // 新しく受け取ったテキスト
          const text = chunk.choices[0]?.delta?.content || '';
          // ストリームに追加
          controller.enqueue(text);
        }
        // ストリームを閉じる
        controller.close();
      },
    }),
    {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    },
  );
});

export default {
  port: 3000,
  fetch: app.fetch,
};

4.4. 動作確認

bun index.ts

http://localhost:3000/で立ち上がった場合、下記のようなリクエストで動作確認します。

curl -X POST http://localhost:3000/openai/stream/ \
  -H "Content-Type: application/json" \
  -d '{"message": "今日の晩ごはんのレシピを考えて"}'

バックエンド完了!!!


5. フロントエンド(React)の実装

5.1. Reactプロジェクト作成

npm create vite@latest

質問に答えていく

✔ Project name: … frontend
✔ Select a framework: › React
✔ Select a variant: › TypeScript

この章はここで作成した /frontend 配下で作業を行います。

cd frontend

5.2. 【本題】 フロントページ実装

ここも理解が難しい部分はコメント入れてます!
※スタイルは省略

react App.tsx
import React, { useState, useRef, useEffect } from 'react'
import './App.css'

type Message = {
  role: 'user' | 'assistant'
  content: string
}

function App() {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [streamData, setStreamData] = useState('') // ストリームデータを一時的に格納
  const [isLoading, setIsLoading] = useState(false)
  const messagesEndRef = useRef<HTMLDivElement>(null)

  // 最後のメッセージまでスクロール(必須機能ではないです)
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }

  useEffect(() => {
    scrollToBottom()
  }, [messages])

  // ユーザからメッセージが送信された際の処理
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!input.trim() || isLoading) return

    const userMessage = { role: 'user', content: input }
    setMessages(prev => [...prev, userMessage])
    setInput('')
    setIsLoading(true)

    try {
      // 4章で作成したAPIへリクエスト
      const response = await fetch("http://localhost:3000/openai/stream/", {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message: input })
      })
      const reader = response.body?.getReader()
      const decoder = new TextDecoder()
      let fullText = ''

      while (true) {
        const { done, value } = await reader?.read() || { done: true, value: null }
        if (done) break // ストリームの終了が宣言されたらループ終了
        const text = decoder.decode(value, { stream: true })
        setStreamData(prev => prev + text)
        fullText += text
      }
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: fullText
      }])
    } catch (error) {
      console.error('Error:', error)
      setMessages(prev => [...prev, {
        role: 'assistant',
        content: 'エラーが発生しました。もう一度お試しください。'
      }])
    } finally {
      setStreamData('')
      setIsLoading(false)
    }
  }

  return (
    <div className="chat-container">
      <div className="messages">
        {messages.map((message, index) => (
          <div
            key={index}
            className={`message ${message.role === 'user' ? 'user' : 'assistant'}`}
          >
            <div className="message-content">{message.content}</div>
          </div>
        ))}
        {streamData && (
          <div className="message ai">
            <div className="message-content">{streamData}</div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>
      <form onSubmit={handleSubmit} className="input-form">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="メッセージを入力..."
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading || !input.trim()}>
          送信
        </button>
      </form>
    </div>
  )
}

export default App

5.3. 動作確認

npm run dev

これで冒頭にお見せしたチャットアプリが再現されていると思います!

Video to GIF Converter.gif


6. まとめ

この記事では、Hono.jsとReact、OpenAI APIを組み合わせて、AIチャットアプリをゼロから構築しました。
ストリーム形式でレスポンスを受け取ることでスムーズなユーザー体験を簡単に実現することができました!

また、Hono.jsやBunなど個人的に新しい技術に触れる良い機会にもなりました。

最後まで読んでいただきありがとうございました!!!

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?