はじめに
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システムの概要
- BetterChatGPTからいらない機能を除き、Cloud Runで動かします
- Cloud Load BalancingとIAPによりログイン管理をします
- 必要であれば、Cloud Amorにより、アクセス元のIPアドレスを制限します
手順書
- リポジトリのクローン
- 
        APIの埋め込み
        - .envファイルにAPIを設定
 
- アイコンの変更
- UI画面にアイコンを表示
- 
        いらない機能の削除
        - 概要&スポンサー
- Jing Hua作
- API
- ShareGPT
 
- Cloud Run上で構築
- Cloud Load Balancingの設定
- IAPの設定
- IAPがCloud Run サービスに対して認証できるようにする
- 確認
1. リポジトリをクローン
BetterChatGPTのリポジトリをクローンする。
git clone https://github.com/ztjhz/BetterChatGPT.git2. 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ディレクトリにアイコンを決めている画像があります。
  
 
        
  この中のfavicon*.pngを変えたい画像に変更します。
  
同じ名前でファイルを置き換えれば完了です。
4. UI上にアイコンを表示
UI上のサイドメニュー部分にアイコンを表示させます。
- 
        src/components/Menu内に表示させたいアイコンのファイルを置きます。ファイル名は任意で良いですが、 iconTemplate.pngというファイルを置いています。
- 
        src/components/Menu/Menu.tsxファイルを以下のように変更します。src/components/Menu/Menu.tsximport 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上で構築
- 
        Artifact Registry にリポジトリを作成する
        - [REPOSITORY_NAME]: 適切な名前
 gcloud artifacts repositories create [REPOSITORY_NAME] --location=asia-northeast1 --repository-format=docker
- 
        クローンした リポジトリ内の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]
- 
        Artifact Registry の認証をする
        gcloud auth configure-docker asia-northeast1-docker.pkg.dev
- 
        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]
- 
        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をすぐに利用できます。 
- 
        IPアドレスの確保
        - [IPADDRESS_NAME]: 適切な名前
 gcloud compute addresses create [IPADDRESS_NAME] --project=[PROJECT_ID] --global
7. Cloud Load Balancingの設定
7.1. Cloud Load Balancingの設定
- ロードバランサの作成画面で HTTP(S) ロードバランシングを選択
- 次の画面ではインターネットから VM またはサーバーレス サービスへとグローバル HTTP(S) ロードバランサにチェック
7.2. フロントエンドを構成
- プロトコル 項目にHTTPS(HTTP/2 を含む)を選択
- 先ほど確保した IP アドレスを設定して、証明書を新規作成
- Google マネージドの証明書を作成するを選択し、ドメイン 1 項目 に今回使用するドメイン名を入力します。
7.3. バックエンドを構成
- バックエンドタイプ項目でサーバーレス ネットワーク エンドポイントグループを選択
- 新しいバックエンド 項目でサーバーレス ネットワーク エンドポイントグループを作成
- Cloud Runを選択し、始めに作成した Cloud Run サービスを選択
- サーバーレス ネットワーク エンドポイントグループを作成したらバックエンドサービスとして設定し、ロードバランサを作成
7.4. 証明書のプロビジョニング状態を確認する
- 作成されたロードバランサの詳細画面から、証明書のステータスを確認
- ロードバランサの作成からしばらく待つと、以下のように証明書のステータスが ACTIVE になる
8. IAPの設定
ロードバランサに対して IAP を設定し、組織内のユーザーのみがサービスにアクセスできるようにします。
以下のサイトを参考にIAPを設定します。
- 
        IAM によるアクセス制御の手順に沿って、IAP がトラフィックを Cloud Run バックエンド サービスに送信することを承認します。
        - 
        プリンシパル: service-[PROJECT-NUMBER]@gcp-sa-iap.iam.gserviceaccount.com
- ロール: Cloud Run 起動元
 
- 
        プリンシパル: 
ユーザーに対して IAP 経由のアクセスを許可する
IAP 経由のアクセスができるように、サービスにアクセスする組織内ユーザーに対してIAP-secured Web App User ロールを設定します。
- IAPでプリンシパルを追加をクリック
- サービスにアクセスする組織内ユーザーに対してIAP-secured Web App User ロールを設定
9. IAPがCloud Run サービスに対して認証できるようにする
プロジェクトごとに存在する IAP のサービスアカウントに対して、Cloud Run サービスを呼び出す権限を付与します。
IAP のサービスアカウントのプリンシパル名は以下のような形式になっています。
service-<プロジェクト番号>@gcp-sa-iap.iam.gserviceaccount.com上記のプリンシパルに対して「Cloud Run 起動元」ロールを付与します。
10. 確認
以下のチェック項目をすべて満たしているか確認してください。
以下のような状態になっているか確認してください。
 
        まとめ
今回は、社内用ChatGPT UIの構築手順を紹介しました。詳しく手順を説明しているので参考にしてみてください。BetterChatGPTのファイルを書き換えることで、さらにカスタマイズすることができるので試してみてください!
