ElcamyTECH
Articles
GoogleCloud

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

TechGoogleCloudGoogle CloudOpenAIChatGPTCloud Run生成AI2023/08/15

はじめに

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

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

手順書

メモ

ChatGPT UIの作成手順

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

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

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

bash
git clone https://github.com/ztjhz/BetterChatGPT.git

2. APIの埋め込み

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

python
# 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 この中のfavicon*.pngを変えたい画像に変更します。 同じ名前でファイルを置き換えれば完了です。

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

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

  1. src/components/Menu内に表示させたいアイコンのファイルを置きます。 ファイル名は任意で良いですが、iconTemplate.pngというファイルを置いています。

  2. src/components/Menu/Menu.tsxファイルを以下のように変更します。

    :::details src/components/Menu/Menu.tsx

    typescript
    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
typescript
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
typescript
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: 適切な名前
    bash
    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: 適切な名前
    bash
    docker build ./[*DIRECTORY_NAME*] -t asia-northeast1-docker.pkg.dev/[*PROJECT_ID*]/[*REPOSITORY_NAME*]/[*IMAGE_NAME*]
  3. Artifact Registry の認証をする

    bash
    gcloud auth configure-docker asia-northeast1-docker.pkg.dev
  4. Artifact Registry のリポジトリにイメージをプッシュする

    • PROJECT_ID: GCPのPROJECT_ID
    • REPOSITORY_NAME: 先ほど作成したリポジトリ名
    • IMAGE_NAME: 先ほど作成したIMAGE_NAME
    bash
    docker push asia-northeast1-docker.pkg.dev/[*PROJECT_ID*]/[*REPOSITORY_NAME*]/[*IMAGE_NAME*]
  5. Cloud Run上で、ChatGPT UIを作成する

    • UI_NAME: 適切な名前
    bash
    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をすぐに利用できます。

  1. IPアドレスの確保

    • IPADDRESS_NAME: 適切な名前
    python
    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. サーバーレス ネットワーク エンドポイントグループを作成したらバックエンドサービスとして設定し、ロードバランサを作成

    :::message

    IAPを使う場合は、Cloud CDN オフにする

    :::

7.4. 証明書のプロビジョニング状態を確認する

  1. 作成されたロードバランサの詳細画面から、証明書のステータスを確認
  2. ロードバランサの作成からしばらく待つと、以下のように証明書のステータスが ACTIVE になる

8. IAPの設定

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

https://cloud.google.com/iap/docs/enabling-cloud-run?hl=ja

メモ

以下の部分はユーザーに対して 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. 確認

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

  • ログイン画面が表示されている
  • ファビコンが変更されている
  • いらない機能が削除されている
  • UI上にアイコンが表示されている

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

image

まとめ

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

参考

https://github.com/ztjhz/BetterChatGPT

https://cloud.google.com/iap/docs/enabling-cloud-run?hl=ja

関連記事

Solution

AIエージェント・Dify構築支援

AIエージェント開発・Dify構築・PoC・社内研修まで
ワンストップで支援。まずはお気軽にご相談ください。

Elcamy

Technology Partners

DifyGoogle Cloud Partner