GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

assistant-uiのランタイムをCloud Functions for FirebaseのonCallで実装する #GameWith #TechWith

はじめに

ChatGPTのようなUIでAI Chatを作ることができるassistant-uiというライブラリがあります。 www.assistant-ui.com 今回、この裏側をCloud Functions for Firebaseにしようとしたときに躓いたので忘備録として残しておきます。

また、本記事で紹介する内容はランタイムをCloud Functions for Firebaseにすることですので、上記ライブラリを用いてチャットUIを表示できている前提で話が進みます。

チャットUI表示部分の実装方法は公式ドキュメントをご覧ください。

https://www.assistant-ui.com/docs/getting-started

実装

調べたところassistant-uiの機能としてあるカスタムランタイムを実装することで、バックエンドAPIを好きなものに置き換えることが出来るようです。

今回はバックエンドAPIに、使い慣れているCloud Functions for Firebaseを選択しました。

カスタムランタイムの作り方は、下記ドキュメントを参考にしました。

https://www.assistant-ui.com/docs/runtimes/custom/local

firebase funtionsの実装

AIモデルは適宜変更してください。 この実装はGeminiを使用していますが、ai-sdkを使っているので、ChatGPTにすることも可能です。

※firebase-functions/httpsは、v2じゃないとストリーミングが動かないです。firebase-functionsのバージョンが古い場合は先に確認しておいてください。

index.ts

import { streamText } from "ai";
import {
  CallableRequest,
  CallableResponse,
  HttpsError,
  onCall,
} from "firebase-functions/https";
import { createVertex } from "@ai-sdk/google-vertex";

const vertex = createVertex({
  project: "XXXX", // GCPプロジェクトID
  location: "XXXX", // GCPリージョン us-central1など
  googleAuthOptions: {
    credentials: {
      client_email: "XXXX",
      private_key: "XXXX",
    },
  },
});

type ChatWithAIRequest = {
  messages: any[];
};

export const onCallChatWithAI = async (
  req: CallableRequest<ChatWithAIRequest>,
  res?: CallableResponse
): Promise<void> => {
  try {
    // 送信されたメッセージを取得
    const { messages } = req.data;

    const modelName = "gemini-2.5-pro-preview-05-06"; // 使用したいGeminiモデル名

    const model = vertex(modelName);

    // googleのAIサービスを使ってストリーミングテキストを処理
    const { textStream } = streamText({
      model,
      messages,
    });

    for await (const data of textStream) {
      res?.sendChunk(data);
    }
  } catch (error: any) {
    // エラー処理
    throw new HttpsError("internal", "Call failed", error.message);
  }
};

// ファンクションをエクスポート
// リージョンやタイムアウト、メモリなどは適宜修正してください。
export const chatWithAIStream = onCall(
  { region: "asia-northeast1", timeoutSeconds: 540, memory: "2GiB" },
  onCallChatWithAI
);

フロント側の実装

components/assistant-ui/は、assistant-uiを公式ドキュメント通りにセットアップすれば自動的に生成されているはずです。 が、絶対にここじゃないといけないわけではないので好きなフォルダ内に入れてください。

components/assistant-ui/MyRuntimeProvider.tsx

import type { ReactNode } from "react";
import {
  AssistantRuntimeProvider,
  ThreadMessage,
  useLocalRuntime,
  type ChatModelAdapter,
} from "@assistant-ui/react";
import {
  connectFunctionsEmulator,
  getFunctions,
  httpsCallable,
} from "firebase/functions";
import { getApp, getApps, initializeApp } from "firebase/app";

// firebaseの設定をコンソールから取得して、以下に貼り付けてください。
const firebaseConfig = {
  apiKey: "XXXXXXXXXXXXX",
  authDomain: "XXXXXXXXXXXXX",
  projectId: "XXXXXXXXXXXXX",
  storageBucket: "XXXXXXXXXXXXXXX",
  messagingSenderId: "XXXXXXXXXXXXX",
  appId: "XXXXXXXXXXX",
  measurementId: "XXXXXXXXX",
};

if (getApps().length === 0) {
  initializeApp(firebaseConfig, "プロジェクトID");
}
const firebaseApp = getApp("プロジェクトID");
// 適宜リージョンを変更してください
const functions = getFunctions(firebaseApp, "asia-northeast1");
// ローカルのファンクションエミュレータに接続したい場合は、以下のコメントを外してください。
// if (process.env.NODE_ENV === "development") {
//   connectFunctionsEmulator(functions, "localhost", 5001);
// }

export const MyModelAdapter: ChatModelAdapter = {
  async *run({ messages }) {
    const chatWithAIFunction = httpsCallable<
      unknown,
      { messages: ThreadMessage[] },
      string
    >(functions, "chatWithAIStream");

    const { stream } = await chatWithAIFunction
      .stream({
        messages,
      })
      .catch((error) => {
        // エラー処理
        throw new Error(`Error calling function: ${error.message}`);
      });

    let text = "";
    for await (const chunk of stream) {
      text += chunk;

      yield {
        content: [{ type: "text", text }],
      };
    }
  },
};

export function MyRuntimeProvider({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  const runtime = useLocalRuntime(MyModelAdapter);

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  );
}

components/assistant-ui/AIChat.tsx

チャットUIを表示するコンポーネント

import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
import { ThreadList } from "@/components/assistant-ui/thread-list";
import { Thread } from "@/components/assistant-ui/thread";
import { MyModelAdapter } from "@/components/assistant-ui/MyRuntimeProvider";

const AIChat = () => {
  const runtime = useLocalRuntime(MyModelAdapter);

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      <div className="grid h-dvh grid-cols-[200px_1fr] gap-x-2 p-4">
        <ThreadList />
        <Thread />
      </div>
    </AssistantRuntimeProvider>
  );
};

export default AIChat;

Cloud Functions for Firebaseのローカルエミュレータ起動やデプロイ方法は、他に記事がたくさんあるので詳しく書かないですが、functionsさえ動かせれば、あとはチャットをしてみるとうまく動作するはずです。

出来なかったこと

assistant-uiは、abortSignalを使ってバックエンドAPIに停止シグナルを送ることが出来ますが、現状のfirebase-functionsではそのシグナルを受け取って停止させる方法がなさそうでした。

ユーザ目線では停止ボタンを押すと生成が止まるので、裏側で無駄にAPIが動いていることを許容できるのであれば、Cloud Functions for Firebaseはデプロイ先としていい選択肢になりそうです。

最後に

GameWithではエンジニアを絶賛募集中です!

サーバーエンジニアやフロントエンジニアの方、AIに興味がある方もお気軽にカジュアル面談をお申し込みください!

github.com