生成 AI を活用したシンプルなチャットアプリの開発記録



この記事では、React と Node.js を使用して、Generative AI（Gemini API）を活用した シンプルなチャットアプリを作成する過程を記録します。
特に、AI との会話機能の実装に注力し、エラー処理やデバッグの方法についても触れます。

今回の開発は Chat GPT と対話しながら進めており、本記事は Chat GPT に `ここまでの制作過程を備忘録として Qiita に記事投稿したいです。記事を作成してみて。`と命令して作成しています。
ところどころ説明が足りていない部分もあるかと思いますが、初めての投稿なのでご愛嬌までに軽く読んでいただけると幸いです。


## 背景
以前から、チャットアプリに Generative AI を組み込んで、ユーザーが会話を通じて自然に AI とやり取りできるシステムを作成したいと考えていました。
今回は、Google の Gemini API を使用し、ユーザーがメッセージを送信し、AI がそのメッセージに対して適切な返答を返す仕組みを作りました。

## 開発環境

- **フロントエンド**: React
- **バックエンド**: Node.js (Express)
- **API**: Google Gemini API（Generative AI）
- **その他**: Axios (API通信)

## 1. チャットアプリの基本的な構築

まず、React を使ってシンプルなチャット UI を作成しました。
ユーザーがメッセージを送信できるテキストボックスを設置し、そのメッセージを表示するリストを作成しました。

```jsx
import React, { useState, useEffect } from 'react';
import io from 'socket.io-client';
import './App.css';
import ChatWithAI from "./ChatWithAI";

const socket = io('http://localhost:3001');

function App() {
  const [channels, setChannels] = useState(['General']); // チャンネルリスト
  const [currentChannel, setCurrentChannel] = useState('General'); // 現在のチャンネル
  const [messages, setMessages] = useState([]);
  const [newMessage, setNewMessage] = useState('');
  const [newChannelName, setNewChannelName] = useState('');
  const [replyingTo, setReplyingTo] = useState(null); // 返信対象を管理
  const [showRepliesFor, setShowRepliesFor] = useState(null); // リプライを表示する対象

  useEffect(() => {
    fetchMessages(currentChannel);

    socket.on('newMessage', (message) => {
      if (message.channel === currentChannel) {
        setMessages((prev) => [...prev, message]);
      }
    });

    return () => {
      socket.off('newMessage');
    };
  }, [currentChannel]);

  const fetchMessages = async (channel) => {
    try {
      const response = await fetch(`http://localhost:3001/messages?channel=${channel}`);
      if (!response.ok) throw new Error(`Failed to fetch messages: ${response.status}`);
      const data = await response.json();
      setMessages(data);
    } catch (error) {
      console.error('Failed to fetch messages:', error);
      alert('Failed to fetch messages. Please try again.');
    }
  };

  const sendMessage = async () => {
    if (!newMessage.trim()) return;

    const message = {
      text: newMessage.trim(),
      timestamp: new Date().toISOString(),
      channel: currentChannel,
      parentId: replyingTo ? replyingTo.id : null, // 返信対象があればparentIdをセット
    };

    try {
      const response = await fetch('http://localhost:3001/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });

      if (!response.ok) throw new Error(`Failed to send message: ${response.status}`);

      setNewMessage(''); // 入力フィールドをクリア
      setReplyingTo(null); // 返信後にリセット
    } catch (error) {
      console.error('Failed to send message:', error);
      alert('Failed to send the message. Check console for details.');
    }
  };

  const createChannel = () => {
    if (!newChannelName.trim()) return;

    if (!channels.includes(newChannelName)) {
      setChannels((prev) => [...prev, newChannelName]);
      setNewChannelName('');
    } else {
      alert('This channel already exists.');
    }
  };

  const switchChannel = (channel) => {
    setCurrentChannel(channel);
    fetchMessages(channel); // 切り替え時にメッセージを取得
  };

  const startReplying = (message) => {
    setReplyingTo(message); // 返信対象のコメントをセット
  };

  const handleShowReplies = (parentId) => {
    setShowRepliesFor(showRepliesFor === parentId ? null : parentId); // リプライの表示切り替え
  };

  const renderReplies = (parentId) => {
    return messages
      .filter((msg) => msg.parentId === parentId) // 親コメントに対する返信のみフィルタリング
      .map((reply) => (
        <div key={reply.id} style={{ marginLeft: '20px' }}>
          <div className="message">
            <strong>{new Date(reply.timestamp).toLocaleTimeString()}</strong>: {reply.text}
            <button onClick={() => startReplying(reply)}>Reply</button>
            <button onClick={() => handleShowReplies(reply.id)}>
              {showRepliesFor === reply.id ? 'Hide Replies' : 'Show Replies'}
            </button>
          </div>
          {renderReplies(reply.id)} {/* 再帰的に返信を表示 */}
        </div>
      ));
  };

  return (
    <div className="app">
      <div className="main-container">
        {/* サイドバー */}
        <div className="sidebar">
          <h2>Channels</h2>
          <ul>
            {channels.map((channel, idx) => (
              <li
                key={idx}
                className={channel === currentChannel ? 'active' : ''}
                onClick={() => switchChannel(channel)}
              >
                #{channel}
              </li>
            ))}
          </ul>
          <div className="new-channel">
            <input
              type="text"
              value={newChannelName}
              onChange={(e) => setNewChannelName(e.target.value)}
              placeholder="New channel name"
            />
            <button onClick={createChannel}>Create</button>
          </div>
        </div>

        {/* チャットエリア */}
        <div className="chat-area">
          <div className="chat-header">
            <h2>#{currentChannel}</h2>
          </div>
          <div className="chat-messages">
            {messages
              .filter((msg) => msg.parentId === null) // 親コメントのみ表示
              .map((msg) => (
                <div key={msg.id}>
                  <div className="message">
                    <strong>{new Date(msg.timestamp).toLocaleTimeString()}</strong>: {msg.text}
                    <button onClick={() => startReplying(msg)}>Reply</button>
                    <button onClick={() => handleShowReplies(msg.id)}>
                      {showRepliesFor === msg.id ? 'Hide Replies' : 'Show Replies'}
                    </button>
                  </div>
                  {showRepliesFor === msg.id && (
                    <div>
                      {renderReplies(msg.id)} {/* 親コメントに対する返信を表示 */}
                    </div>
                  )}
                </div>
              ))}
          </div>
          {/* メインメッセージの入力フィールド */}
          <div className="chat-input">
            <input
              type="text"
              value={newMessage}
              onChange={(e) => setNewMessage(e.target.value)}
              placeholder="Type a message"
            />
            <button onClick={sendMessage}>Send</button>
          </div>
        </div>

        {/* 右側の返信エリア + AI チャット */}
        <div className="reply-area">
          {replyingTo && (
            <div>
              <h3>Replying to: {replyingTo.text}</h3>
              <input
                type="text"
                value={newMessage}
                onChange={(e) => setNewMessage(e.target.value)}
                placeholder="Type a reply"
              />
              <button onClick={sendMessage}>Send Reply</button>
            </div>
          )}
          {/* AI チャットを追加 */}
          <ChatWithAI />
        </div>
      </div>
    </div>
  );
}

export default App;

```

---

## 2. AIとの連携（Gemini API）

次に、Node.js サーバーをセットアップし、Gemini API と連携しました。
ユーザーが送信したメッセージを受け取り、Gemini API にリクエストを送り、返ってきた AI の返答をフロントエンドに返します。

```javascript
// server.js
require("dotenv").config();
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const axios = require("axios");
const { v4: uuidv4 } = require("uuid");

const app = express();
const PORT = 3001;

app.use(cors({
  origin: "http://localhost:3000",
  methods: ["GET", "POST", "OPTIONS"],
  allowedHeaders: ["Content-Type"]
}));

// プリフライトリクエストを適切に処理
app.options("*", cors());

app.use(express.json());

const server = http.createServer(app);
const io = new Server(server, { cors: { origin: "http://localhost:3000" } });

const GOOGLE_GEMINI_API_KEY = process.env.GOOGLE_GEMINI_API_KEY;

// チャンネルごとのメッセージを管理するオブジェクト
let messages = {
  General: [],
};

// チャンネル一覧
let channels = ["General"];

// ✅ **チャンネル作成エンドポイント**
app.post("/channels", (req, res) => {
  const { name } = req.body;
  if (!name || channels.includes(name)) {
    return res.status(400).json({ error: "Invalid or duplicate channel name" });
  }
  channels.push(name);
  messages[name] = [];
  res.status(201).json({ success: true, channels });
});

// ✅ **チャンネル一覧取得エンドポイント**
app.get("/channels", (req, res) => {
  res.json({ channels });
});

// ✅ **メッセージ取得エンドポイント**
app.get("/messages", (req, res) => {
  const { channel } = req.query;
  if (!channel || !messages[channel]) {
    return res.status(400).json({ error: "Invalid channel" });
  }
  const parentMessages = messages[channel].filter((msg) => msg.parentId === null);
  res.json(parentMessages);
});

// ✅ **メッセージ送信エンドポイント**
app.post("/messages", (req, res) => {
  const { text, timestamp, channel, parentId } = req.body;
  if (!channel || !text || !timestamp || !messages[channel]) {
    return res.status(400).json({ error: "Invalid message format" });
  }

  const message = {
    id: uuidv4(),
    text,
    timestamp,
    channel,
    parentId: parentId || null,
  };

  messages[channel].push(message);
  io.emit("newMessage", message);
  res.status(201).json({ success: true, message });
});

// ✅ **Gemini AIとのチャットエンドポイント**
app.post("/chat", async (req, res) => {
  try {
    const { message } = req.body;
    if (!message) {
      return res.status(400).json({ error: "Message is required" });
    }

    const response = await axios.post(
      `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GOOGLE_GEMINI_API_KEY}`,
      { contents: [{ parts: [{ text: message }] }] },
    );
    
    // レスポンス全体を詳細に表示
    console.log('Gemini AI Response:', JSON.stringify(response.data, null, 2));
    
    res.json({ reply: response.data.candidates[0].content.parts[0].text });
  } catch (error) {
    console.error("Error calling Gemini API:", error.response?.data || error.message);
    res.status(500).json({ error: `Failed to connect to Gemini API: ${error.message}` });
  }
});// ✅ **ソケット通信の設定**
io.on("connection", (socket) => {
  console.log("New client connected");
  socket.on("disconnect", () => {
    console.log("Client disconnected");
  });
});

// ✅ **サーバー起動**
server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

```

```jsx
// ChatWithAI.js
import { useState } from "react";
import axios from "axios";

const ChatWithAI = () => {
  const [input, setInput] = useState("");
  const [messages, setMessages] = useState([]);

  const sendMessage = async () => {
    if (!input.trim()) return;

    const userMessage = { sender: "You", text: input };
    setMessages([...messages, userMessage]);

    try {
      const response = await axios.post("http://localhost:3001/chat", { message: input });
      const aiMessage = { sender: "AI", text: response.data.reply };
      setMessages([...messages, userMessage, aiMessage]);
    } catch (error) {
      console.error("Error sending message:", error);
      setMessages([...messages, userMessage, { sender: "AI", text: "エラーが発生しました。" }]);
    }

    setInput("");
  };

  return (
    <div style={{ padding: "20px", border: "1px solid #ccc", borderRadius: "8px", width: "300px" }}>
      <h3>AI Chat</h3>
      <div style={{ height: "200px", overflowY: "auto", marginBottom: "10px", border: "1px solid #ddd", padding: "5px" }}>
        {messages.map((msg, index) => (
          <p key={index}><strong>{msg.sender}: </strong>{msg.text}</p>
        ))}
      </div>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="メッセージを入力..."
        style={{ width: "100%", padding: "5px", marginBottom: "5px" }}
      />
      <button onClick={sendMessage} style={{ width: "100%" }}>送信</button>
    </div>
  );
};

export default ChatWithAI;
```

---

### 3. エラー処理とデバッグ

実装中、いくつかのエラーが発生しました。最も多かったのは、API からのレスポンスが空白だったり、正しいデータが返ってこないというものでした。以下のようなエラーが発生した場合、サーバーログとフロントエンドのコンソールログを活用してデバッグしました。

- **エラー例 1**: `Invalid JSON payload received`
  - **原因**: APIリクエストで不正なフォーマットを送信した
  - **対応**: リクエストの形式やパラメータを再確認し、正しい形式に修正

- **エラー例 2**: `Cannot find field "prompt"`
  - **原因**: Gemini API へのリクエストで、正しいフィールド名（`prompt`）を指定していなかった
  - **対応**: APIドキュメントを参照し、必要なフィールド名を修正

- **エラー例 3**: `AIの返答が空白`
  - **原因**: サーバーからAIのレスポンスが正しく返っていなかった
  - **対応**: サーバーログに出力し、Gemini API のレスポンスを詳細に確認

フロントエンド側でも、`console.log` を使って AI からのレスポンスが正しく受け取られているかを確認しました。

---

## 4. サーバーログの活用

サーバーサイドでは、APIからのレスポンスをログに出力することで、問題がどこで発生しているかを特定しやすくしました。以下のように、Gemini API のレスポンスをログに表示します。

```javascript
// サーバー側
console.log("Gemini API Response:", response.data);
```

これにより、API から返されたデータが期待通りであるか、またはエラーが発生していないかを簡単に確認できます。

---

### 結果

- フロントエンドとバックエンドが適切に連携し、ユーザーが送信したメッセージに対して AI が返答するチャットアプリが完成しました。
- 実際に AI との会話を楽しむことができ、デバッグを通じて多くの学びがありました。
---
## 今後の改善点

- **エラーハンドリングの強化**: 現在はエラーメッセージを単純に表示していますが、よりユーザーに優しいエラーメッセージやリトライ機能を追加したいです。
- **AI の精度向上**: 現在使用している AI モデル（Gemini API）に加えて、他のモデルやカスタマイズを試し、より自然な返答を得られるようにしたいです。
- **UX/UI の改善**: チャットUIを改善し、より直感的で使いやすいデザインに進化させる予定です。