テックブログ

エルカミーの技術ブログです

社内用 ChatGPT UI の構築手順書(BetterChatGPT)

はじめに


ChatGPTをより安全に使うため

これからChatGPTを社内に導入したいという場合があると思います。ChatGPTを導入する際に考慮が必要なのは、入力した情報が流出しないなどのセキュリティ対策です。セキュリティ対策をするために独自UIを構築するユースケースがあります。独自UIを構築するのにOSSのライブラリを使うと便利です。

OSSのライブラリは、例えば下記があります。

  • BetterChatGPT
  • ChatbotUI

今回は、GCPとBetterChatGPT を使用して、セキュリティ対策を考慮した、社内用 ChatGPT UI の構築手順を紹介したいと思います。

必要技術


  • Docker
  • Cloud Run
  • Cloud Load Balancing
  • IAP(Identity-Aware Proxy)
  • Cloud Amor(オプション)

ChatGPT UIシステムの概要


image block
  • BetterChatGPTからいらない機能を除き、Cloud Runで動かします
  • Cloud Load BalancingとIAPによりログイン管理をします
  • 必要であれば、Cloud Amorにより、アクセス元のIPアドレスを制限します

手順書


ChatGPT UIの作成手順
  1. リポジトリのクローン
  2. APIの埋め込み
    1. .envファイルにAPIを設定
  3. アイコンの変更
  4. UI画面にアイコンを表示
  5. いらない機能の削除
    1. 概要&スポンサー
    2. Jing Hua作
    3. API
    4. ShareGPT
  6. Cloud Run上で構築
  7. Cloud Load Balancingの設定
  8. IAPの設定
  9. IAPがCloud Run サービスに対して認証できるようにする
  10. 確認

1. リポジトリをクローン

BetterChatGPTのリポジトリをクローンする。

git clone https://github.com/ztjhz/BetterChatGPT.git
2. APIの埋め込み

.env.exampleファイルがあります。このファイルをコピーし、.envにリネームします。

.envファイルは以下のようになります。

# All options are optional, remove those you do not need
VITE_CUSTOM_API_ENDPOINT=
VITE_DEFAULT_API_ENDPOINT=
VITE_OPENAI_API_KEY=YOUR_OPENAI_API_KEY
VITE_DEFAULT_SYSTEM_MESSAGE=     # Remove this line if you want to use the default system message of Better ChatGPT
VITE_GOOGLE_CLIENT_ID=           # for syncing data with google drive

VITE_OPENAI_API_KEYに自身のOpenAI APIを入力します。

3. アイコンの変更

publicディレクトリにアイコンを決めている画像があります。

image block

この中のfavicon*.pngを変えたい画像に変更します。

同じ名前でファイルを置き換えれば完了です。

4. UI上にアイコンを表示

UI上のサイドメニュー部分にアイコンを表示させます。

  1. src/components/Menu内に表示させたいアイコンのファイルを置きます。

    ファイル名は任意で良いですが、iconTemplate.pngというファイルを置いています。

  2. src/components/Menu/Menu.tsxファイルを以下のように変更します。
    src/components/Menu/Menu.tsx
    import React, { useEffect, useRef } from 'react';
    
    import useStore from '@store/store';
    
    import NewChat from './NewChat';
    import NewFolder from './NewFolder';
    import ChatHistoryList from './ChatHistoryList';
    import MenuOptions from './MenuOptions';
    
    import CrossIcon2 from '@icon/CrossIcon2';
    import DownArrow from '@icon/DownArrow';
    import MenuIcon from '@icon/MenuIcon';
    import IconImage from './iconTemplate.png';
    
    const Menu = () => {
      const hideSideMenu = useStore((state) => state.hideSideMenu);
      const setHideSideMenu = useStore((state) => state.setHideSideMenu);
    
      const windowWidthRef = useRef<number>(window.innerWidth);
    
      useEffect(() => {
        if (window.innerWidth < 768) setHideSideMenu(true);
        window.addEventListener('resize', () => {
          if (
            windowWidthRef.current !== window.innerWidth &&
            window.innerWidth < 768
          )
            setHideSideMenu(true);
        });
      }, []);
    
      return (
        <>
          <div
            id='menu'
            className={`group/menu dark bg-gray-900 fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col transition-transform z-[999] top-0 left-0 h-full max-md:w-3/4 ${
              hideSideMenu ? 'translate-x-[-100%]' : 'translate-x-[0%]'
            }`}
          >
            <div className='flex h-full min-h-0 flex-col'>
              <div className='flex h-full w-full flex-1 items-start border-white/20'>
                <nav className='flex h-full flex-1 flex-col space-y-1 px-2 pt-2'>
                  <img src={IconImage} alt='Icon' className='w-[64px] h-[64px]'/>
                  <div className='flex gap-2'>
                    <NewChat />
                    <NewFolder />
                  </div>
                  <ChatHistoryList />
                  <MenuOptions />
                </nav>
              </div>
            </div>
            <div
              id='menu-close'
              className={`${
                hideSideMenu ? 'hidden' : ''
              } md:hidden absolute z-[999] right-0 translate-x-full top-10 bg-gray-900 p-2 cursor-pointer hover:bg-black text-white`}
              onClick={() => {
                setHideSideMenu(true);
              }}
            >
              <CrossIcon2 />
            </div>
            <div
              className={`${
                hideSideMenu ? 'opacity-100' : 'opacity-0'
              } group/menu md:group-hover/menu:opacity-100 max-md:hidden transition-opacity absolute z-[999] right-0 translate-x-full top-10 bg-gray-900 p-2 cursor-pointer hover:bg-black text-white ${
                hideSideMenu ? '' : 'rotate-90'
              }`}
              onClick={() => {
                setHideSideMenu(!hideSideMenu);
              }}
            >
              {hideSideMenu ? (
                <MenuIcon className='h-4 w-4' />
              ) : (
                <DownArrow className='h-4 w-4' />
              )}
            </div>
          </div>
          <div
            id='menu-backdrop'
            className={`${
              hideSideMenu ? 'hidden' : ''
            } md:hidden fixed top-0 left-0 h-full w-full z-[60] bg-gray-900/70`}
            onClick={() => {
              setHideSideMenu(true);
            }}
          />
        </>
      );
    };
    
    export default Menu;
5. いらない機能の削除

5.1. 概要 & スポンサー , Jing Hua作, APIの削除

src/components/Menu/MenuOptions/MenuOptions.tsxでメニューのオプションを表示させています。

このファイルの<AboutMenu />, <Me />, <Api />が以下の機能を表示させています。

  • 概要 & スポンサー(<AboutMenu />)
  • Jing Hua作(<Me />)
  • API(<Api />)

そのため、この部分をコメントアウトすることで削除します。

具体的には、以下のようにファイルを変更します。

src/components/Menu/MenuOptions/MenuOptions.tsx
import React from 'react';
import useStore from '@store/store';

import Api from './Api';
import Me from './Me';
import AboutMenu from '@components/AboutMenu';
import ImportExportChat from '@components/ImportExportChat';
import SettingsMenu from '@components/SettingsMenu';
import CollapseOptions from './CollapseOptions';
import GoogleSync from '@components/GoogleSync';
import { TotalTokenCostDisplay } from '@components/SettingsMenu/TotalTokenCost';

const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined;

const MenuOptions = () => {
  const hideMenuOptions = useStore((state) => state.hideMenuOptions);
  const countTotalTokens = useStore((state) => state.countTotalTokens);
  return (
    <>
      <CollapseOptions />
      <div
        className={`${
          hideMenuOptions ? 'max-h-0' : 'max-h-full'
        } overflow-hidden transition-all`}
      >
        {countTotalTokens && <TotalTokenCostDisplay />}
        {googleClientId && <GoogleSync clientId={googleClientId} />}
        {/* <AboutMenu /> */}
        <ImportExportChat />
        {/* <Api /> */}
        <SettingsMenu />
        {/* <Me /> */}
      </div>
    </>
  );
};

export default MenuOptions;
5.2. ShareGPTの機能削除

src/components/ShareGPT/ShareGPT.tsxファイルでShareGPTに投稿のボタン表示させています。

このファイルの<button>により、ShareGPTに投稿を表示させています。

そのため、この部分をコメントアウトすることで削除します。

具体的には、以下のようにファイルを変更します。

src/components/ShareGPT/ShareGPT.tsx
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';

import PopupModal from '@components/PopupModal';
import { submitShareGPT } from '@api/api';
import { ShareGPTSubmitBodyInterface } from '@type/api';

const ShareGPT = React.memo(() => {
  const { t } = useTranslation();
  const [isModalOpen, setIsModalOpen] = useState<boolean>(false);

  const handleConfirm = async () => {
    const chats = useStore.getState().chats;
    const currentChatIndex = useStore.getState().currentChatIndex;
    if (chats) {
      try {
        const items: ShareGPTSubmitBodyInterface['items'] = [];
        const messages = document.querySelectorAll('.share-gpt-message');

        messages.forEach((message, index) => {
          items.push({
            from: 'gpt',
            value: `<p><b>${t(
              chats[currentChatIndex].messages[index].role
            )}</b></p>${message.innerHTML}`,
          });
        });

        await submitShareGPT({
          avatarUrl: '',
          items,
        });
        setIsModalOpen(false);
      } catch (e: unknown) {
        console.log(e);
      }
    }
  };

  return (
    <>
      {/* <button
        className='btn btn-neutral'
        onClick={() => {
          setIsModalOpen(true);
        }}
      >
        {t('postOnShareGPT.title')}
      </button>
      {isModalOpen && (
        <PopupModal
          setIsModalOpen={setIsModalOpen}
          title={t('postOnShareGPT.title') as string}
          message={t('postOnShareGPT.warning') as string}
          handleConfirm={handleConfirm}
        />
      )} */}
    </>
  );
});

export default ShareGPT;
6. Cloud Run上で構築

  1. Artifact Registry にリポジトリを作成する
    • [REPOSITORY_NAME]: 適切な名前
    gcloud artifacts repositories create [REPOSITORY_NAME] --location=asia-northeast1 --repository-format=docker
  2. クローンした リポジトリ内のDocker ファイルをイメージにビルドする
    • [DIRECTORY_NAME]: 1. リポジトリのクローンで作成したディレクトリ名
    • [PROJECT_ID]: GCPのPROJECT_ID
    • [REPOSITORY_NAME]: 先ほど作成したリポジトリ名
    • [IMAGE_NAME]: 適切な名前
    docker build ./[DIRECTORY_NAME] -t asia-northeast1-docker.pkg.dev/[PROJECT_ID]/[REPOSITORY_NAME]/[IMAGE_NAME]
  3. Artifact Registry の認証をする
    gcloud auth configure-docker asia-northeast1-docker.pkg.dev
  4. Artifact Registry のリポジトリにイメージをプッシュする
    • [PROJECT_ID]: GCPのPROJECT_ID
    • [REPOSITORY_NAME]: 先ほど作成したリポジトリ名
    • [IMAGE_NAME]: 先ほど作成したIMAGE_NAME
    docker push asia-northeast1-docker.pkg.dev/[PROJECT_ID]/[REPOSITORY_NAME]/[IMAGE_NAME]
  5. Cloud Run上で、ChatGPT UIを作成する
    • [UI_NAME]: 適切な名前
    gcloud run deploy [UI_NAME] \
      --ingress=internal-and-cloud-load-balancing \
      --image asia-northeast1-docker.pkg.dev/[PROJECT_ID]/[REPOSITORY_NAME]/[IMAGE_NAME] \
      --allow-unauthenticated \
      --region=asia-northeast1

    ログイン管理をする必要のない場合は、--ingress=allに変更してください。

    この変更を行えば、ChatGPT UIをすぐに利用できます。

  6. IPアドレスの確保
    • [IPADDRESS_NAME]: 適切な名前
    gcloud compute addresses create [IPADDRESS_NAME] --project=[PROJECT_ID] --global
7. Cloud Load Balancingの設定

7.1. Cloud Load Balancingの設定
  1. ロードバランサの作成画面で HTTP(S) ロードバランシングを選択
  2. 次の画面ではインターネットから VM またはサーバーレス サービスへグローバル HTTP(S) ロードバランサにチェック
7.2. フロントエンドを構成
  1. プロトコル 項目にHTTPS(HTTP/2 を含む)を選択
  2. 先ほど確保した IP アドレスを設定して、証明書を新規作成
  3. Google マネージドの証明書を作成するを選択し、ドメイン 1 項目 に今回使用するドメイン名を入力します。
7.3. バックエンドを構成
  1. バックエンドタイプ項目でサーバーレス ネットワーク エンドポイントグループを選択
  2. 新しいバックエンド 項目でサーバーレス ネットワーク エンドポイントグループを作成
  3. Cloud Runを選択し、始めに作成した Cloud Run サービスを選択
  4. サーバーレス ネットワーク エンドポイントグループを作成したらバックエンドサービスとして設定し、ロードバランサを作成
IAPを使う場合は、Cloud CDN オフにする
7.4. 証明書のプロビジョニング状態を確認する
  1. 作成されたロードバランサの詳細画面から、証明書のステータスを確認
  2. ロードバランサの作成からしばらく待つと、以下のように証明書のステータスが ACTIVE になる
8. IAPの設定

ロードバランサに対して IAP を設定し、組織内のユーザーのみがサービスにアクセスできるようにします。

以下のサイトを参考にIAPを設定します。

以下の部分はユーザーに対して IAP 経由のアクセスを許可するの項目の操作を行ってください。
  1. IAM によるアクセス制御の手順に沿って、IAP がトラフィックを Cloud Run バックエンド サービスに送信することを承認します。
    • プリンシパルservice-[PROJECT-NUMBER]@gcp-sa-iap.iam.gserviceaccount.com
    • ロールCloud Run 起動元
ユーザーに対して IAP 経由のアクセスを許可する

IAP 経由のアクセスができるように、サービスにアクセスする組織内ユーザーに対してIAP-secured Web App User ロールを設定します。

  1. IAPでプリンシパルを追加をクリック
  2. サービスにアクセスする組織内ユーザーに対してIAP-secured Web App User ロールを設定
9. IAPがCloud Run サービスに対して認証できるようにする

プロジェクトごとに存在する IAP のサービスアカウントに対して、Cloud Run サービスを呼び出す権限を付与します。

IAP のサービスアカウントのプリンシパル名は以下のような形式になっています。

service-<プロジェクト番号>@gcp-sa-iap.iam.gserviceaccount.com

上記のプリンシパルに対して「Cloud Run 起動元」ロールを付与します。

10. 確認

以下のチェック項目をすべて満たしているか確認してください。

ログイン画面が表示されている
ファビコンが変更されている
いらない機能が削除されている
API
概要&スポンサー
Jing Hua作
ShareGPT
UI上にアイコンが表示されている

以下のような状態になっているか確認してください。

image block

まとめ


今回は、社内用ChatGPT UIの構築手順を紹介しました。詳しく手順を説明しているので参考にしてみてください。BetterChatGPTのファイルを書き換えることで、さらにカスタマイズすることができるので試してみてください!

参考