💨

Vertex AI in Firebase SDK を使って、Next.jsで簡単なチャット機能を作成する。

2024/11/27に公開

はじめに

こんにちは、クラウドエースの第3開発部に所属している金です。
今回は、Vertex AI in Firebase SDK を使って、Next.js で簡単なチャット機能を実装する方法についてご紹介します。

対象読者

  • Firebase SDK の Vertex AI に興味がある方
  • 簡単に Gemini API を使ってみたい方
  • Next.js での開発経験少しでもある方

事前準備

サービスの概要

1. Vertex AI in Firebase SDK とは?

Vertex AI in Firebase SDK は、Firebase SDK を使用して、Google Cloud の最新の生成 AI モデルである Gemini モデルにアクセスできます。サーバーサイドではなく、モバイルアプリまたはウェブアプリから直接 Vertex AI Gemini API を呼び出すことができます。

Gemini 1.5 Flash

  • 1.5 Pro と同じ入力および出力タイプ(および合計トークン数)をサポートするマルチモーダルモデル。
  • 特に大容量でコスト効率の高いアプリケーション向け。

Gemini 1.5 Pro

  • テキストまたはチャット プロンプトに画像、音声、動画、PDF ファイルを追加できるマルチモーダルモデル。
  • 最大 100 万個のトークンで長いコンテキストの理解をサポート。

Gemini 1.0 Pro Vision

  • テキストまたはコード レスポンスで、テキスト、画像、動画を処理するように設計されたマルチモーダル モデル。

Gemini 1.0 Pro

  • 自然言語タスク、テキストとコードを使用したマルチターン チャットやコード生成を処理するように設計されたモデル。

2. 対応 Gemini モデル詳細

Gemini 1.5 Flash Gemini 1.5 Pro Gemini 1.0 Pro Vision Gemini 1.0 Pro
トークンの合計上限 1,048,576 トークン 2,097,152 トークン 16,384 トークン 32,760 トークン
出力トークンの上限 8,192 トークン 8,192 トークン 8,192 トークン 8,192 トークン
使用可能なモデル gemini-1.5-flash-002 (安定版)
gemini-1.5-flash-001 (安定版)
gemini-1.5-flash (自動更新バージョン)
gemini-1.5-pro-002 (安定版)
gemini-1.5-pro-001 (安定版)
gemini-1.5-pro (自動更新バージョン)
gemini-1.0-pro-vision-001 (安定版)
gemini-1.0-pro-vision (自動更新バージョン)
gemini-1.0-pro-002 (安定版)
gemini-1.0-pro-001 (安定版)
gemini-1.0-pro (自動更新バージョン)
テキストのみの入力からのテキスト生成
マルチモーダル入力からのテキスト生成 不可
システム指示 不可 不可

※ こちらは 2024年 11月時点の情報になります。
※ 最新情報を知りたい方は、こちらのドキュメントを参照ください。
※ 本記事では、gemini-1.5-flash を使用します。詳細については、こちらをご参照ください。

実装手順

Firebase プロジェクトの作成

  1. Firebase コンソールにアクセスします。

  2. プロジェクトを作成します。

  3. 左側の「Build with Gemini」をクリックし、少しスクロールダウンします。

  4. Vertex AI in Firebase の下にある「Get Started」ボタンを押します。

    image

    ガイドに従って設定すると Gemini API が使えるようになります。

アプリから直接 Vertex AI Gemini API を呼び出す設定

※ 以下の設定はフレームワークに依存しないため、Next.js 以外のフレームワークでも利用可能です。

  1. ご自身の Next.js プロジェクトルートに以下のコマンドを実行してください。
npm install firebase
  1. 1.が完了したら、以下のコードをご自身の Next.js プロジェクトに追加してください。
    ※ firebaseConfig の値については Firebase コンソール → プロジェクトの設定 → SDK の設定と構成にて確認できます。
import { initializeApp } from "firebase/app";

// ご参考: https://firebase.google.com/docs/web/learn-more#config-object

const firebaseConfig = {
  // 必要な環境関数を入れる
  apiKey: "",
  authDomain: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: "",
  appId: "",
};

// Firebase  を初期化
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);

// Vertex AI service を初期化
const vertexAI = getVertexAI(app);
// Gemini 1.5 model を指定 (他の Gemini Versionも指定可能)
const model = getGenerativeModel(vertexAI, { model: "gemini-1.5-flash" });

他の設定

以下のようにパラメータ値を設定することで、生成されるコンテンツの品質を調整できます。
※ 各パラメータの詳細についてはこちらをご参照ください。

import { HarmBlockThreshold, HarmCategory, getGenerativeModel } from "firebase/vertexai";

// ...

const generationConfig = {
  max_output_tokens: 200,  // 出力トークンの上限を設定。1 トークンは約 4 文字(日本語)、または約 20 単語(英語)に相当
  stop_sequences: ["end"], // 特定のシーケンスで文章生成を停止。例:"end" という文字列が出現した時点で生成を終了
  temperature: 0.9,       // 温度パラメータ(0.1~1.0)、 低いほど確実で一貫性のある応答、高いほど創造的で多様な応答を生成
  top_p: 0.1,            // トークンの確率分布の上位p%に基づいてサンプリング。例:0.1 の場合、確率の上位 10% のトークンのみを対象に選択
  top_k: 5,              // トークンの確率分布の上位k個のトークンからサンプリング。例:5 の場合、最も確率の高い上位 5 個のトークンから選択
};

const model = getGenerativeModel(vertex, { model: "gemini-1.5-flash", generationConfig });

// ...

上記の基本設定に加えて、安定性設定も可能です。
以下のように設定することで、不適切な回答が生成される可能性を軽減できます。
安定性設定にについてこちらをご参照ください。

import { HarmBlockThreshold, HarmCategory, getGenerativeModel } from "firebase/vertexai";

// ...

const safetySettings = [
  {
    category: HarmCategory.HARM_CATEGORY_HARASSMENT,
    threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH,
  },
];

const model = getGenerativeModel(vertex, { model: "gemini-1.5-flash", safetySettings });

// ...

チャット機能の実装 (基本編)

以下のコードでは、generateContent 関数を使用してユーザー入力からコンテンツを生成します。

export される model では、以下の 2 つの方法でレスポンスを取得できます。

  • ストリーミング形式で受け取る(generateContentStream)
  • 結果が完全に生成されるまで待機する(generateContent)

これらの関数は、会話の文脈を必要としない単発のリクエストに適しています。

  • テキスト翻訳
  • コード分析
  • その他の単発処理

ご参考: Gemini API を使用してテキストのみのプロンプトからテキストを生成する

// src/lib/firebase/index.ts

import { initializeApp } from "firebase/app";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);

const vertexAI = getVertexAI(app);

const model = getGenerativeModel(vertexAI, { model: "gemini-1.5-flash" });

export {
  model
};

geminiRun 関数を作成し、チャット機能を実装。

// src/utils/gemini.ts
import { model } from "@/lib/firebase";

export const geminiRun = async (userInput: string) => {
  // userInput: ユーザーの入力
  // ユーザーの入力を元にコンテンツを生成
  try {
    const result = await model.generateContent(userInput);
    const response = result.response;
    return response.text();
  } catch (error) {
    throw new Error(`error generating content: ${error}`);
  }
};

チャットの全体コードは以下のようになります。
UI は Chakra UI(v2) を使用しています。

// src/app/chat/page.tsx

"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
  Button,
  Input,
  Text,
  Box,
  VStack,
  HStack,
  Spinner,
  Container,
} from "@chakra-ui/react";
import { TbMessageChatbot } from "react-icons/tb";
import { geminiRun } from "@/utils/gemini";

// チャットメッセージの送信者
type Sender = "user" | "gemini";

// チャットメッセージの型
type Chat = {
  sender: Sender;
  content: string;
};

function Page() {
  // 初期メッセージ
  const INITIAL_MESSAGE: Chat = {
    sender: "gemini",
    content: "お手伝いできることはありますか?",
  };

  // チャット履歴状態管理
  const [chatHistory, setChatHistory] = useState<Chat[]>([INITIAL_MESSAGE]);

  // ユーザーが入力したメッセージ状態管理
  const [message, setMessage] = useState("");

  // AIが返答中かどうかのフラグ管理
  const [isAiResponding, setIsAiResponding] = useState(false);

  // IME入力中かどうかのフラグ管理
  // IME入力中はEnterキーでの送信を無効化するため
  const [isComposing, setIsComposing] = useState(false);

  // チャットコンテナのref
  const chatContainerRef = useRef<HTMLDivElement>(null);

  // チャットコンテナを一番下にスクロールする関数
  const scrollToBottom = () => {
    if (chatContainerRef.current) {
      chatContainerRef.current.scrollTop =
        chatContainerRef.current.scrollHeight;
    }
  };

  // チャット履歴が変更されたときに、チャットコンテナを一番下にスクロールする
  useEffect(() => {
    scrollToBottom();
  }, [chatHistory]);

  // メッセージ送信処理
  const handleSendMessage = async () => {
    if (message.trim()) {
      const userMessage = message.trim();
      setChatHistory((prev) => [
        ...prev,
        { sender: "user", content: userMessage },
      ]);
      setMessage("");
      setIsAiResponding(true);

      try {
        // gemini apiを呼び出す関数
        const response = await geminiRun(message);
        setChatHistory((prev) => [
          ...prev,
          { sender: "gemini", content: response },
        ]);
      } catch (error) {
        console.error(error);
        setChatHistory((prev) => [
          ...prev,
          { sender: "gemini", content: "エラーが発生しました。" },
        ]);
      } finally {
        setIsAiResponding(false);
        setTimeout(scrollToBottom, 100);
      }
    }
  };

  // ユーザーが入力したメッセージが変更されたときの処理
  const handleInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setMessage(e.target.value);
    },
    []
  );

  // ユーザーがEnterキーを押したときの処理
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && !e.shiftKey && !isComposing && !isAiResponding) {
        e.preventDefault();
        handleSendMessage();
      }
    },
    [isComposing, isAiResponding, handleSendMessage, message]
  );

  return (
    <Container maxW="container.xl" h="100vh" py={4}>
      <VStack h="full" spacing={4}>
        <Box w="full" flex={1} overflowY="auto">
          <VStack
            spacing={4}
            align="stretch"
            overflowY="auto"
            ref={chatContainerRef}
            pr={2}
            h="calc(100vh - 150px)"
            pb={4}
            p={10}
          >
            {chatHistory && chatHistory.length > 0 ? (
              chatHistory.map((chat, index) => (
                <HStack
                  key={index}
                  justifyContent={
                    chat.sender === "user" ? "flex-end" : "flex-start"
                  }
                >
                  {chat.sender !== "user" && (
                    <Box fontSize="24px">
                      <TbMessageChatbot />
                    </Box>
                  )}
                  <Box
                    bg={chat.sender === "user" ? "gray.200" : "transparent"}
                    color="black"
                    p={3}
                    borderRadius="md"
                    maxW="80%"
                  >
                    <Text as="span">{chat.content}</Text>
                  </Box>
                </HStack>
              ))
            ) : (
              <Text>Loading...</Text>
            )}
            {isAiResponding && (
              <Box bg="transparent" p={2} borderRadius="md">
                <Text as="span">
                  thinking...{" "}
                  <Box as="span" display="inline-block">
                    <Spinner size="sm" />
                  </Box>
                </Text>
              </Box>
            )}
          </VStack>
        </Box>

        <Box w="full" p={4} borderTop="1px" borderColor="gray.200">
          <HStack spacing={4}>
            <Input
              placeholder="メッセージを入力してください"
              value={message}
              onChange={handleInputChange}
              onCompositionStart={() => setIsComposing(true)}
              onCompositionEnd={() => setIsComposing(false)}
              onKeyDown={handleKeyDown}
            />
            <Button
              colorScheme="teal"
              onClick={handleSendMessage}
              isDisabled={isAiResponding ? true : false}
            >
              send
            </Button>
          </HStack>
        </Box>
      </VStack>
    </Container>
  );
}

export default Page;

チャット機能の実装 (応用編 : マルチターン対応)

マルチターンの会話ができるチャット機能を実装するために、startChat() を使用します。
ご参考:チャット

まず、基本編で作成した gemini.ts を以下のように修正します。

// src/utils/gemini.ts

import { model } from "@/lib/firebase";
import type { ChatSession } from "firebase/vertexai-preview";

// chatをグローバル変数として保持
let chat: ChatSession | null = null;

export const geminiRun = async (userInput: string) => {
  try {
    // chatがない場合は新しいchatを作成
    if (!chat) {
      chat = model.startChat({
        generationConfig: {
          maxOutputTokens: 100, // 出力トークンの上限を設定
        },
        //systemInstruction: "You are a cat. Your name is Neko.", // システム指示設定も可能 (本記事では使用しません。)
      });
    }

    // ユーザーの入力を送信
    const result = await chat.sendMessage(userInput);
    const response = result.response;
    return response.text();
  } catch (error) {
    throw new Error(`error generating content: ${error}`);
  }
};

// 必要に応じてchatをリセット
export const resetChat = () => {
  chat = null;
};

page.tsx を以下のように修正します。

// src/app/chat/page.tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
  Button,
  Input,
  Text,
  Box,
  VStack,
  HStack,
  Spinner,
  Container,
} from "@chakra-ui/react";
import { TbMessageChatbot } from "react-icons/tb";
import { geminiRun, resetChat } from "@/utils/gemini";

// チャットメッセージの送信者
type Sender = "user" | "gemini";

// チャットメッセージの型
type Chat = {
  sender: Sender;
  content: string;
};

function Page() {
  // ユーザーが入力したメッセージ状態管理
  const [message, setMessage] = useState("");

  // チャット履歴状態管理
  const [chatHistory, setChatHistory] = useState<Chat[]>([]);

  // AIが返答中かどうかのフラグ管理
  const [isAiResponding, setIsAiResponding] = useState(false);

  // IME入力中かどうかのフラグ管理
  const [isComposing, setIsComposing] = useState(false);

  // チャットコンテナのref
  const chatContainerRef = useRef<HTMLDivElement>(null);

  // チャットコンテナを一番下にスクロールする関数
  const scrollToBottom = () => {
    if (chatContainerRef.current) {
      chatContainerRef.current.scrollTop =
        chatContainerRef.current.scrollHeight;
    }
  };

  // 必要ではないですが、以下の処理も追加
  // - コンポネントがアンマウントされたときに chat をリセット
  useEffect(() => {
    return () => {
      resetChat();
    };
  }, []);

  // チャット履歴が変更されたときに、チャットコンテナを一番下にスクロールする
  useEffect(() => {
    scrollToBottom();
  }, [chatHistory]);
  
  // メッセージ送信処理
  const handleSendMessage = async () => {
    if (message.trim()) {
      const userMessage = message.trim();
      setChatHistory((prev) => [
        ...prev,
        { sender: "user", content: userMessage },
      ]);
      setMessage("");
      setIsAiResponding(true);

      try {
        const response = await geminiRun(userMessage);
        setChatHistory((prev) => [
          ...prev,
          { sender: "gemini", content: response },
        ]);
      } catch (error) {
        console.error(error);
        setChatHistory((prev) => [
          ...prev,
          { sender: "gemini", content: "エラーが発生しました。" },
        ]);
      } finally {
        setIsAiResponding(false);
        setTimeout(scrollToBottom, 100);
      }
    }
  };

  // ユーザーが入力したメッセージが変更されたときの処理
  const handleInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setMessage(e.target.value);
    },
    []
  );

  // ユーザーがEnterキーを押したときの処理
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (
        e.key === "Enter" &&
        !e.shiftKey &&
        !isComposing &&
        !isAiResponding &&
        message.trim()
      ) {
        e.preventDefault();
        handleSendMessage();
      }
    },
    [isComposing, isAiResponding, handleSendMessage, message]
  );

  return (
    <Container maxW="container.xl" h="100vh" py={4}>
      <VStack h="full" spacing={4}>
        <Box w="full" flex={1} overflowY="auto">
          <VStack
            spacing={4}
            align="stretch"
            overflowY="auto"
            ref={chatContainerRef}
            pr={2}
            h="calc(100vh - 150px)"
            pb={4}
            p={10}
          >
            {/* 初期メッセージ */}
            {chatHistory.length === 0 && (
              <HStack justifyContent="flex-start">
                <Box fontSize="24px">
                  <TbMessageChatbot />
                </Box>
                <Box
                  bg="transparent"
                  color="black"
                  p={3}
                  borderRadius="md"
                  maxW="80%"
                >
                  <Text as="span">お手伝いできることはありますか?</Text>
                </Box>
              </HStack>
            )}
            {chatHistory.map((chat, index) => (
              <HStack
                key={index}
                justifyContent={
                  chat.sender === "user" ? "flex-end" : "flex-start"
                }
              >
                {chat.sender !== "user" && (
                  <Box fontSize="24px">
                    <TbMessageChatbot />
                  </Box>
                )}
                <Box
                  bg={chat.sender === "user" ? "gray.200" : "transparent"}
                  color="black"
                  p={3}
                  borderRadius="md"
                  maxW="80%"
                >
                  <Text as="span">{chat.content}</Text>
                </Box>
              </HStack>
            ))}
            {isAiResponding && (
              <Box bg="transparent" p={2} borderRadius="md">
                <Text as="span">
                  thinking...{" "}
                  <Box as="span" display="inline-block">
                    <Spinner size="sm" />
                  </Box>
                </Text>
              </Box>
            )}
          </VStack>
        </Box>

        <Box w="full" p={4} borderTop="1px" borderColor="gray.200">
          <HStack spacing={4}>
            <Input
              placeholder="メッセージを入力してください"
              value={message}
              onChange={handleInputChange}
              onCompositionStart={() => setIsComposing(true)}
              onCompositionEnd={() => setIsComposing(false)}
              onKeyDown={handleKeyDown}
              isDisabled={isAiResponding}
            />
            <Button
              colorScheme="teal"
              onClick={handleSendMessage}
              isDisabled={isAiResponding || !message.trim()}
            >
              送信
            </Button>
          </HStack>
        </Box>
      </VStack>
    </Container>
  );
}

export default Page;

上記の通りに実装すると、Gemini が会話のコンテキストを保持し、マルチターンの会話が可能になります。

まとめ

良い点

気になる点

  • プレビュー段階のため、安定性に懸念がある

Vertex AI in Firebase SDK を使って、Next.js で簡単なチャット機能を実装する方法についてご紹介しました。
現在はまだプレビュー段階のため、本番環境に組み込む際には注意が必要ですが、PoC (概念実証) などで活用するには十分な機能が揃っていると思います。
ご興味をお持ちの方は、ぜひお試しください。

Discussion