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

## 0. はじめに
この記事では、AIチャットアプリを作成していきます！

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

![Video to GIF Converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1894050/1f94cf74-e6a6-0700-379f-ac9fac2b3117.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** を採用しました。これも個人的には初の試みです。

https://hono.dev/

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

https://ja.react.dev/

### 1.3. OpenAI API
今回作るチャットアプリの要です。

https://openai.com/index/openai-api/

---

## 2. 事前準備 - OpenAI APIキー発行
OpenAI APIを利用するためには、OpenAI API Keyを発行する必要があります。

:::note warn
OpenAI APIの利用には料金が発生します
料金体制は[こちら](https://platform.openai.com/docs/pricing)をご確認ください
:::

### 2.1. OpenAIアカウントを作成する
登録画面は[こちら](https://chatgpt.com/)

### 2.2. 支払い情報を登録する
[Settings > Billing](https://platform.openai.com/settings/organization/billing/overview) から残高をチャージできるはずです。


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

![スクリーンショット 2025-02-08 14.28.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1894050/e9b6b163-466f-58f3-014b-8715d65115c6.png)

### 2.3. APIキーを発行する
[Settings > API keys](https://platform.openai.com/settings/organization/api-keys) からAPIキーを作成します。
APIキーは一度しか表示されないためメモしておいてください。

![スクリーンショット 2025-02-08 14.37.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1894050/47684fdc-311e-1821-0c6f-d6e4a519d9a3.png)

---

## 3. 構成
成果物がイメージしやすいように最終的な構成を示しておきます。
ほとんどのファイルが後述する `bun init` や `npm 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` 配下で作業を行います。
```shell
# ルートディレクトリ作成（ここではプロジェクト名をai_chat_appとしてます）
mkdir ai_chat_app
cd ai_chat_app

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

:::note
[Bun](https://bun.sh/) を使うのでまだ入っていない方は下記コマンドでインストールします。

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

### 4.1. bun init
```shell
bun init
```
質問に答えていく
```shell
package name (backend): # パッケージ名の入力
entry point (index.ts): # エントリーポイントの入力
```

### 4.2. 必要なパッケージのインストール
```shell
# 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. 動作確認
```shell
bun index.ts
```
`http://localhost:3000/`で立ち上がった場合、下記のようなリクエストで動作確認します。

```shell
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` 配下で作業を行います。
```shell
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. 動作確認
```shell
npm run dev
```
これで冒頭にお見せしたチャットアプリが再現されていると思います！

![Video to GIF Converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1894050/8c66fc5d-ec06-1060-3891-0e71b951f9eb.gif)

---

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

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


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