Top View


Author Daiki Urata

Expo + Firebase Extensions(Gemini API)でバックエンド実装なしでAIチャットボットアプリを作る

2024/03/11

はじめに

認証やデータベースなどバックエンドのサービスを提供しているFirebaseですが、Firebase Extensionsの1つにBuild Chatbot with the Gemini API というものが出てきたので、実際にモバイルアプリに組み込んでみます。

環境

  • Expo SDK 50
  • googlecloud/firestore-genai-chatbot@0.0.9 (Firebase Extension)
  • Firebase SDK v10.8.1
  • Node v20.0.0

Expoプロジェクト作成

npx create-expo-app --template

? Choose a template:- Use arrow-keys. Return to submit.
    Blank
Blank (TypeScript) - blank app with TypeScript enabled
    Navigation (TypeScript)
    Blank (Bare)

cd プロジェクト名
npm run start

Firebaseのセットアップ

次にFirebase SDKをインストール、設定値ファイルを作成します。

今回はFirebaseプロジェクトの作成については省略します。わからない方は以下を参考にしてみてください。

参考: https://docs.expo.dev/guides/using-firebase/#using-firebase-js-sdk

npx expo install firebase

firebaseConfig.tsを作成します。設定値はFirebaseコンソールから取得してください。

import { initializeApp } from 'firebase/app';
import { getFirestore }  from 'firebase/firestore'

const firebaseConfig = {
  apiKey: 'api-key',
  authDomain: 'project-id.firebaseapp.com',
  databaseURL: 'https://project-id.firebaseio.com',
  projectId: 'project-id',
  storageBucket: 'project-id.appspot.com',
  messagingSenderId: 'sender-id',
  appId: 'app-id',
  measurementId: 'G-measurement-id',
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);

Firebase Extensionのインストール

そして今回メインであるFirebase ExtensionのBuild Chatbot with the Gemini API を作成したFirebaseプロジェクトへインストールします。

「Install in Firebase console」をクリックします。

Installation guide for Firebase Extension Chatbot with Gemini

すると以下のような設定値などを入力する画面になります。

Setup guide for Chatbot with Gemini - 1

このExtensionを使用するにはBlazeプラン(有料)へのアップグレードが必要ですので注意が必要です。

Setup guide for Chatbot with Gemini - 2

ここに書かれているGoogle AI API Keyはこのあと取得方法を説明します。

Setup guide for Chatbot with Gemini - 3

各サービスを有効化する必要がありました。有効化していない場合は「Enable」を押していけばOKです。

Setup guide for Chatbot with Gemini - 4

今回はProviderとしてGoogle AIを選択したので、Google AI API Keyが必要になります。API Keyは AI Studio で作成します。

あとで変更しますが、Firestore Collection Pathは一旦デフォルトのgenerateで良いです。

Setup guide for Chatbot with Gemini - 5

以下はGoogle AI Studioの画面になります。

今回はGoogle AI Studioについては書きませんが、これはWeb上でGoogleのAIモデルGeminiを試すことができるツールで、ChatGPTのようにチャットしたり、APIキーを発行できます。

詳しくはドキュメントを参照してください。

今回はAPIキーだけ必要なので、「Create API key」からAPIキーを発行します。

Creating API key at Google AI Studio

APIキーを発行できたので、Firebaseコンソールに戻りって「Google AI API Key 」フィールドに入力します。

続けてCloud Functionなどの設定値を入力していきます。

今回入力した値は以下画像のようになりました。

Setup guide for Chatbot with Gemini - 6

ここら辺は基本デフォルトにしました。

Setup guide for Chatbot with Gemini - 7

危険なコンテンツなどをブロックする設定などもできるようです。

Setup guide for Chatbot with Gemini - 8

Functionについての設定などはインストール後に変更できず、再インストールが必要になりますので、気をつけてください。

Setup guide for Chatbot with Gemini - 9

入力が完了してインストールを開始したら、数分で完了します。

Firestoreデータベース作成

Extensionのインストールが終わったら、次にFirestoreのデータベースを作成します。

Creating firestore database

一旦動かしたいのでtest modeで作成しました。

Firestore test mode

Collectionの作成

これでインストールしたExtensionを動かす環境が整ったので、試しにコンソールから動かしてみます。

まず「Start collection」を押して、Extensionインストール時に設定したCollection Pathの generate Collectionを作ります。

その後、Documentが作れるので、prompt FieldでValueを How are you today? などと入力して保存します。

Creating a firestore collection

するとしばらくすると、以下画像のようにresponsecreateTimeフィールドなどが自動で追加されるはずです。

うまくExtensionが動いて、promptに入力した質問に対してAIが回答してくれました。

AI response sample in firestore database

セッションごとに会話できるようFirestore Collection Pathを編集する

無事にExtensionが動いていることが確認できたので、いよいよアプリに組み込んでいきます。

まずはExtensionの設定へ行き(サイドバーのExtensions > Build Chatbot with the Gemini API > Reconfigure extension)、Firestore Collection Pathを画像のように変更します。

デフォルトのgenerateのままだと、会話毎にチャットメッセージを分けることはできないので sessions/{sessionId}/messages のような形にします。

Modifying config for chatbot with gemini

FirestoreのRulesを編集する

先ほど設定したCollection PathへアクセスできるようにFirestoreのRulesを編集します。

Firebase Consoleのサイドバーから「Firestore Database」> タブ > 「Rules」を開いて以下のように書きます。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
  	match /sessions/{document=**} {
    	allow read, write: if true;
    }
  	match /sessions/{sessionId}/messages/{document=**} {
    	allow read, write: if true;
    }
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

これでCollection Path /sessions/{sessionId}/messages へのみreadとwriteが許可されるようになりました。

実際はここにFirebase Authでのユーザー情報を見て制御して運用することにはなるはずですが、今回は動かすことが目的なので、このままで進めます。

チャットUI作成

次にUIを実装します。

今回は簡単にチャットUIを作成できる react-native-gifted-chat を使います。

インストール

npm install react-native-gifted-chat

実装

Chat.tsxというファイルを作成、以下のように書きます。

import React, { useState, useCallback } from "react";
import { GiftedChat, IMessage } from "react-native-gifted-chat";
import { Button, View, useWindowDimensions } from "react-native";

const chatbot = {
  _id: "chatbot",
  name: "Gemini Chatbot",
};

const me = {
  _id: "me",
  name: "Me",
};

export function Chat() {
  const [messages, setMessages] = useState<IMessage[]>([
    {
      _id: "FIRST_MESSAGE",
      text: "How can I help you today?",
      createdAt: new Date(),
      user: chatbot,
    },
  ]);
  const { width } = useWindowDimensions();

  const onSend = useCallback(
    async (messages: IMessage[] = []) => {
      setMessages((previousMessages) =>
        GiftedChat.append(previousMessages, messages)
      );
    },
    []
  );

  const clearChat = async () => {
    setMessages([]);
  };

  return (
    <View>
      <Button title="Clear Chat" onPress={clearChat} />
      <GiftedChat
        messagesContainerStyle={{
          width,
        }}
        messages={messages}
        onSend={(messages) => onSend(messages)}
        user={me}
      />
    </View>
  );
}

そして作成したChatコンポーネントをApp.tsxに持ってきます。

import { Platform, SafeAreaView, StatusBar, StyleSheet } from "react-native";
import { Chat } from "./Chat";

export default function App() {
  return (
    <SafeAreaView style={styles.container}>
      <Chat />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
    paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0,
  },
});

これでチャットUIは完成です。

Firestoreとの通信処理

あとはFirestoreの /sessions/{sessionId}/messages に対してメッセージを送信し、返ってきたレスポンスを検知して表示するだけになります。

Chat.tsxを編集していきます。

メッセージデータの型定義

まずはFirestoreに保存するメッセージデータの型を定義します。

type FirestoreMessage = {
  createTime?: Timestamp;
  prompt: string;
  response?: string;
  status?: {
    completeTime: Timestamp;
    startTime: Timestamp;
    state: "COMPLETED" | "PROCESSING" | "ERRORED";
  };
};

  • prompt : ユーザーが送信するメッセージ
  • response : Firebase Extensionから生成されたレスポンス
  • status.state : 現在の処理状態。COMPLETEDになれば画面に表示させます

セッション作成

Collection Pathの sessions に対してidを発行してセッションを作成します。

このセッション内でBotとの会話します。

const createSession = async () => {
  const res = await addDoc(collection(db, "sessions"), {});
  return res.id;
};

さらにコンポーネント内では現在セッションの状態管理します。

const [sessionId, setSessionId] = useState<string>();

そして初期描画時にセッションを開始します。

useEffect(() => {
    // Create a new session
    (async () => {
      const id = await createSession();
      setSessionId(id);
    })();
  }, []);

メッセージを送る

メッセージを入力してSendボタンを押した際にFirestoreへ保存する処理を書きます。

const onSend = useCallback(
    async (messages: IMessage[] = []) => {
      setMessages((previousMessages) =>
        GiftedChat.append(previousMessages, messages)
      );
      const collectionRef = collection(
        db,
        `sessions/${sessionId}/messages`
      ) as CollectionReference<FirestoreMessage>;

      const prompt = messages.at(-1)?.text ?? "";

      // Add a new message to firestore
      await addDoc(collectionRef, {
        prompt,
      });
    },
    [sessionId]
  );

これで sessions/{sessionId}/messages に対してメッセージデータを保存して、しばらくするとGeminiから生成されたレスポンスを含んだデータに更新されます。

レスポンスを検知する

生成されたレスポンスを検知するには onSnapshot() を使います。

useEffect(() => {
    const collectionRef = collection(
      db,
      `sessions/${sessionId}/messages`
    ) as CollectionReference<FirestoreMessage>;
    const q = query(collectionRef, orderBy("status.startTime", "desc"));
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const doc = snapshot.docs.at(0);

      if (doc === undefined) return;

      const data = doc.data();
      const _id = doc.id;

      if (data.status?.state !== "COMPLETED") return;

      const text = data.response ?? "";

      // seconds to milliseconds
      const createdAt = new Date(data.status.startTime.seconds * 1000);

      const message = {
        _id,
        text,
        createdAt,
        user: chatbot,
      };
      setMessages((previousMessages) =>
        GiftedChat.append(previousMessages, [message])
      );
    });

    return () => {
      unsubscribe();
    };
  }, [sessionId]);

ここで行なっていることは、以下です。

  1. メッセージデータをstartTimeで新しい順にソート
  2. メッセージデータに更新があれば、1番目(最新)のデータを取得
  3. status.state がCOMPLETEDならレスポンスを取得
  4. レスポンスをチャットUIに反映

チャットのクリア

チャットのクリアは簡単で、新しくセッションを再作成するだけです。

const clearChat = async () => {
    setMessages([]);
    const id = await createSession();
    setSessionId(id);
  };

実装結果

最終的なコードは以下になります。

import React, { useState, useCallback, useEffect } from "react";
import { GiftedChat, IMessage } from "react-native-gifted-chat";
import { db } from "./firebaseConfig";
import {
  CollectionReference,
  Timestamp,
  addDoc,
  collection,
  onSnapshot,
  orderBy,
  query,
} from "firebase/firestore";
import { Button, View, useWindowDimensions } from "react-native";

const chatbot = {
  _id: "chatbot",
  name: "Gemini Chatbot",
};

const me = {
  _id: "me",
  name: "Me",
};

type FirestoreMessage = {
  createTime?: Timestamp;
  prompt: string;
  response?: string;
  status?: {
    completeTime: Timestamp;
    startTime: Timestamp;
    state: "COMPLETED" | "PROCESSING" | "ERRORED";
  };
};

const createSession = async () => {
  const res = await addDoc(collection(db, "sessions"), {});
  return res.id;
};

export function Chat() {
  const [messages, setMessages] = useState<IMessage[]>([
    {
      _id: "FIRST_MESSAGE",
      text: "How can I help you today?",
      createdAt: new Date(),
      user: chatbot,
    },
  ]);
  const [sessionId, setSessionId] = useState<string>();
  const { width } = useWindowDimensions();
  const [isTyping, setIsTyping] = useState(false);

  useEffect(() => {
    const collectionRef = collection(
      db,
      `sessions/${sessionId}/messages`
    ) as CollectionReference<FirestoreMessage>;
    const q = query(collectionRef, orderBy("status.startTime", "desc"));
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const doc = snapshot.docs.at(0);

      if (doc === undefined) return;

      const data = doc.data();
      const _id = doc.id;

      if (data.status?.state !== "COMPLETED") return;

      const text = data.response ?? "";

      // seconds to milliseconds
      const createdAt = new Date(data.status.startTime.seconds * 1000);

      const message = {
        _id,
        text,
        createdAt,
        user: chatbot,
      };
      setMessages((previousMessages) =>
        GiftedChat.append(previousMessages, [message])
      );
      setIsTyping(false);
    });

    return () => {
      unsubscribe();
    };
  }, [sessionId, setIsTyping]);

  useEffect(() => {
    // Create a new session
    (async () => {
      const id = await createSession();
      setSessionId(id);
    })();
  }, []);

  const onSend = useCallback(
    async (messages: IMessage[] = []) => {
      setMessages((previousMessages) =>
        GiftedChat.append(previousMessages, messages)
      );
      const collectionRef = collection(
        db,
        `sessions/${sessionId}/messages`
      ) as CollectionReference<FirestoreMessage>;

      const prompt = messages.at(-1)?.text ?? "";
      // Add a new message to firestore
      await addDoc(collectionRef, {
        prompt,
      });
      setIsTyping(true);
    },
    [sessionId, setIsTyping]
  );

  const clearChat = async () => {
    setMessages([]);
    const id = await createSession();
    setSessionId(id);
  };

  return (
    <View>
      <Button title="Clear Chat" onPress={clearChat} />
      <GiftedChat
        messagesContainerStyle={{
          width,
        }}
        messages={messages}
        onSend={(messages) => onSend(messages)}
        user={me}
        isTyping={isTyping}
      />
    </View>
  );
}

これを動かしてみると以下のようになります。

Chatbot with gemini demo

【おまけ】タイプライターっぽいアニメーションをつける

これでチャットボットは完成しましたが、おまけとしてChatGPTのようなタイプライター風アニメーションをつけてみます。

手前味噌ですが、自分で作成したreact-native-typewriter-effectというライブラリを使用します。

npm install react-native-typewriter-effect

Chat.tsxを編集します。

// 省略...
import { MessageText } from "react-native-gifted-chat";
import { StyleSheet } from "react-native";

const styles = StyleSheet.create({
  textStyle: {
    fontSize: 16,
    lineHeight: 20,
    marginTop: 5,
    marginBottom: 5,
    marginLeft: 10,
    marginRight: 10,
  },
});

export function Chat() {
	// 省略...
  return (
    <View>
      <Button title="Clear Chat" onPress={clearChat} />
      <GiftedChat
	      // 省略...
        renderMessageText={(props) =>
          props.position === "left" ? (
            <TypeWriterEffect
              style={styles.textStyle}
              content={props.currentMessage?.text ?? ""}
            />
          ) : (
            <MessageText {...props} />
          )
        }
      />
    </View>
  );
}

動かしてみると、以下のようになります。

Chatbot typewriter animation demo

おわりに

実際にFirebaseのExtensionを使用して、GeminiによるAIをモバイルアプリに組み込んでみましたが、実際に書いたコードはReact Native側のコードのみでした。

Geminiを利用したFirebase Extensionsは他にもMultimodal Tasks with the Gemini APIというテキストや画像の入力からレスポンスを生成できるものがあるので、こちらも後で試してみたいと思います。

参考

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。