Difyでフロントエンドをカスタムする

## はじめに
Difyをセルフホスティングで環境構築したけど、フロント部分をカスタムしたいと思い、設定をまとめました

[セルフホスティングの環境構築方法はこちら](https://qiita.com/bokazuya25/items/75138036753e5115bd7e)

## 環境
- サーバー: Ubuntu 24.04 LTS

## 変更箇所
- 2025年4月10日現在、モバイル版でチャット履歴をタップしても、サイドバー外をタップしないとチャット画面に戻ることができない（バグっぽいけど）
使いずらいので、チャット履歴をタップしたらサイドバーを閉じるように変更する
- 複数のDifyを取りまとめるため、ダッシュボード画面を用意する

![IMG_1915.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3534893/372e5d98-9da4-40c9-8f49-9e0c2da99443.jpeg)
*タイトルを押したら、裏で画面は切り替わるがサイドバーは閉じない*

## 手順
### フロントを修正する
1. `dify/web/app/components/base/chat/chat-with-history/sidebar/index.tsx`を以下に修正
    ```diff_typescript: 23行目付近～
    type Props = {
      isPanel?: boolean
    + onClose?: () => void
    }

    - const Sidebar = ({ isPanel }: Props) => {
    + const Sidebar = ({ isPanel, onClose }: Props) => {
      const { t } = useTranslation()
      const {
        appData,
        handleNewConversation,
        pinnedConversationList,
        conversationList,
        currentConversationId,
        handleChangeConversation,
        handlePinConversation,
        handleUnpinConversation,
        conversationRenaming,
        handleRenameConversation,
        handleDeleteConversation,
        sidebarCollapseState,
        handleSidebarCollapse,
        isMobile,
        isResponding,
      } = useChatWithHistoryContext()
    ```

    ```diff_typescript:52行目付近～
    const handleOperate = useCallback((type: string, item: ConversationItem) => {
        if (type === 'pin')
          handlePinConversation(item.id)
    
        if (type === 'unpin')
          handleUnpinConversation(item.id)
    
        if (type === 'delete')
          setShowConfirm(item)
    
        if (type === 'rename')
          setShowRename(item)
      }, [handlePinConversation, handleUnpinConversation])
    + const handleChangeConversationWrapper = useCallback((conversationId: string) => {
    +    handleChangeConversation(conversationId)
    +    if (onClose)
    +      onClose()
    +  }, [handleChangeConversation, onClose])
      const handleCancelConfirm = useCallback(() => {
        setShowConfirm(null)
      }, [])
    ```

    ```diff_typescript:114行目付近～
    <div className='h-0 grow space-y-2 overflow-y-auto px-3 pt-4'>
        {/* pinned list */}
        {!!pinnedConversationList.length && (
          <div className='mb-4'>
            <List
              isPin
              title={t('share.chat.pinnedTitle') || ''}
              list={pinnedConversationList}
    -         onChangeConversation={handleChangeConversation}
    +         onChangeConversation={handleChangeConversationWrapper}
              onOperate={handleOperate}
              currentConversationId={currentConversationId}
            />
          </div>
        )}
        {!!conversationList.length && (
          <List
            title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
            list={conversationList}
    -       onChangeConversation={handleChangeConversation}
    +       onChangeConversation={handleChangeConversationWrapper}
            onOperate={handleOperate}
            currentConversationId={currentConversationId}
          />
        )}
    ```
    <br>
1. `dify/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx`を修正
    ```diff_typescript:104行目付近～
    {showSidebar && (
      <div className='fixed inset-0 z-50 flex bg-background-overlay p-1'
        onClick={() => setShowSidebar(false)}
      >
        <div className='flex h-full w-[calc(100vw_-_40px)] rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-sm' onClick={e => e.stopPropagation()}>
    -     <Sidebar />
    +     <Sidebar isPanel onClose={() => setShowSidebar(false)} />
        </div>
      </div>
    )}
    ```

### ダッシュボードの設置
1. `dify/docker/nginx` ディレクトリに `html` ディレクトリを作成し、`dashboard.html` を設置する
    ```bash:dify/docker/nginx#
    # htmlディレクトリの作成
    touch html/dashboard.html

    # dashboard.htmlの中身を作成
    nano html/dashboard.html
    ```
    <br>
2. ルーティングとBasic認証の追加
    ```diff_yaml:dify/docker/nginx/conf.d#
    location / {
      proxy_pass http://web:3000;
      include proxy.conf;
    }

    + location /dashboard {
    +   auth_basic "Restricted Access";
    +   auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
    +   root /usr/share/nginx/html;
    +   index dashboard.html;
    +   try_files /dashboard.html $uri $uri/ =404;
    + }
    + 
    # placeholder for acme challenge location
    ${ACME_CHALLENGE_LOCATION}

    ```

### Basic認証の追加
/dashboardにBasic認証をつけて、公開制限を行う
1. apache2-utilsをインストール
    ```bash:bash
    sudo apt update
    sudo apt install apache2-utils
    ```
    <br>
1. htpasswdコマンドでパスワードファイルを作成
    ```bash:dify/docker/nginx/conf.d#
    sudo htpasswd -c ./.htpasswd <ユーザー名>
    ```
    <br>
1. パスワードの入力を求められるので、2回入力して設定完了

### 環境変更
1. docker-compose-template.yamlを変更
    DifyはDocker Hubのイメージを使用しているため、これを`dify/web/`のディレクトリを見るように変更してあげないといけない

    ```diff_yaml:dify/docker/docker-compose-template.yaml
    # Frontend web application.
      web:
    -  image: langgenius/dify-web:1.2.0
    +  build:
    +    context: ../web
    +    dockerfile: Dockerfile
        restart: always
        environment:
          CONSOLE_API_URL: ${CONSOLE_API_URL:-}
          APP_API_URL: ${APP_API_URL:-}
          SENTRY_DSN: ${WEB_SENTRY_DSN:-}
          NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
          TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
          CSP_WHITELIST: ${CSP_WHITELIST:-}
          MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
          MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
          TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
          INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
          PM2_INSTANCES: ${PM2_INSTANCES:-2}
          LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
          MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
          MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
          MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-5}
    ```
    <br>
    ダッシュボードの設置先を指定する
    
    ```diff_yaml:691行目付近～
    nginx:
      image: nginx:latest
      restart: always
      volumes:
        - ./nginx/nginx.conf.template:/etc/nginx/nginx.conf.template
        - ./nginx/proxy.conf.template:/etc/nginx/proxy.conf.template
        - ./nginx/https.conf.template:/etc/nginx/https.conf.template
        - ./nginx/conf.d:/etc/nginx/conf.d
    +   - ./nginx/html:/usr/share/nginx/html
        - ./nginx/docker-entrypoint.sh:/docker-entrypoint-mount.sh
        - ./nginx/ssl:/etc/ssl # cert dir (legacy)
        - ./volumes/certbot/conf/live:/etc/letsencrypt/live # cert dir (with certbot container)
        - ./volumes/certbot/conf:/etc/letsencrypt
        - ./volumes/certbot/www:/var/www/html
    ```
    <br>
1. `docker-compose-template.yaml` を `docker-compose.yaml` に反映する
    ```bash:~dify/docker#
    python3 generate_docker_compose 
    ```

### 起動
1. webをbuild形式にしたため、`docker compose build`を実施後起動
    ```bash:~/dify/docker#
    docker compose up -d
    ```

### 確認
モバイル版でサイドバーを表示し、タイトルをタップしたらチャットが切り替わり、サイドバーが閉じるようになりました。