GenU のフロントエンド (Vite + React) 詳細解説　②GenU ユースケースページ

## はじめに

皆さん、こんにちは。

私は業務でデータ利活用基盤を取り扱っていること、2024 AWS Japan Top Engineer に選出されたということから、[AWS GenU](https://aws-samples.github.io/generative-ai-use-cases-jp/) およびそれに必要なデータ基盤の探求 ([Snowflake](https://www.snowflake.com/ja/), [dbt](https://www.getdbt.com/), [Iceberg](https://iceberg.apache.org/), etc) に取り組む必要があると考えています。

本投稿では、[GenU のバックエンドである CDK コード](https://github.com/aws-samples/generative-ai-use-cases-jp/tree/main/packages/cdk)を詳細に解説します。
自身そして閲覧して頂いた皆様の GenU への理解が少しでも深まり、生成 AI の民主化につながっていければと考えています。

## GenU とは

GenU は生成 AI を安全に業務活用するための、ビジネスユースケース集です。
バックエンドは AWS で、フロントエンドは Vite + React で動作します。

GenU のバックエンド (2025/3/10 時点) やデプロイオプションについては、詳細解説記事を投稿していますので、詳しく知りたい方はお読みいただけると嬉しいです。([記事末尾参考](#参考-genu-のバックエンド-cdk-詳細解説投稿一覧))

本記事では GenU フロントエンドの各ページの仕様について解説します。

### GenU ユースケースページ

GenU ユースケースページ ([web/src/App.tsx](https://github.com/aws-samples/generative-ai-use-cases-jp/blob/56583580fbc767c70ca451a09cc98ce7c299b998/packages/web/src/App.tsx)) は GenU のメイン機能である各ユースケースの親ページとなります。

まず、GenU ユースケースページの Hook を見ていきましょう。

```typescript: App.tsx (抜粋)
const App: React.FC = () => {
  const { switchOpen: switchDrawer, opened: isOpenDrawer } = useDrawer();
  const { pathname } = useLocation();
  const { getChatTitle } = useChatList();
  const { isShow } = useInterUseCases();
  const { screen, notifyScreen, scrollTopAnchorRef, scrollBottomAnchorRef } =
    useScreen();
  const { enabled } = useUseCases();

  /* 略 */
}
```

##### useDrawer フック

useDrawer フックはカスタムフックであり、実体は[packages/web/src/hooks/useDrawer.ts](https://github.com/aws-samples/generative-ai-use-cases/blob/a255682938ab24f6482f5d1f72795c076d0a2496/packages/web/src/hooks/useDrawer.ts) です。

```typescript: App.tsx (抜粋)
import useDrawer from './hooks/useDrawer';
```

```typescript: useDrawer.ts
import { create } from 'zustand';

const useDrawerState = create<{
  opened: boolean;
  switchOpen: () => void;
}>((set) => {
  return {
    opened: false,
    switchOpen: () => {
      set((state) => ({
        opened: !state.opened,
      }));
    },
  };
});

const useDrawer = () => {
  const [opened, switchOpen] = useDrawerState((state) => [
    state.opened,
    state.switchOpen,
  ]);

  return {
    opened,
    switchOpen,
  };
};

export default useDrawer;
```

状態管理モジュールである [zustand](https://github.com/pmndrs/zustand) を利用しています。
useDrawer の戻り値である switchOpen メソッドを呼び出すと、useDrawer の戻り値にもなっている boolean 型 state の opened を反転します。

##### useLocation フック

useLocation フックは `react-router-dom` で提供されます。pathname は URL のドメイン部分を除いたパスです。
https://example.com/path/to/page?hoge=fuga#hash の場合、`/path/to/page` が取得できます。
その他にも search は `?hoge=fuga`、hash は`#hash`が取得できます。

```typescript
const { pathname, search, hash } = useLocation();
```

##### useChatList フック

useChatList フックはカスタムフックであり、実体は[packages/web/src/hooks/useChatList.ts](https://github.com/aws-samples/generative-ai-use-cases/blob/a255682938ab24f6482f5d1f72795c076d0a2496/packages/web/src/hooks/useChatList.ts) です。

```typescript: App.tsx (抜粋)
import useChatList from './hooks/useChatList';
```

```typescript: useChatList.ts
import { produce } from 'immer';
import useChatApi from './useChatApi';
import { Chat } from 'generative-ai-use-cases-jp';
import usePagination from './usePagination';

const useChatList = () => {
  const { listChats, deleteChat: deleteChatApi, updateTitle } = useChatApi();
  const {
    data,
    flattenData: chats,
    mutate,
    isLoading,
    canLoadMore,
    loadMore,
  } = usePagination(listChats(), 100);

  const deleteChat = async (chatId: string) => {
    mutate(
      produce(data, (draft) => {
        if (data && draft) {
          for (const d in data) {
            const idx = data[d].data.findIndex(
              (c) => c.chatId === `chat#${chatId}`
            );

            if (idx > -1) {
              draft[d].data.splice(idx, 1);
              break;
            }
          }
        }
      }),
      {
        revalidate: false,
      }
    );

    return deleteChatApi(chatId).finally(() => {
      mutate();
    });
  };

  const updateChatTitle = async (chatId: string, title: string) => {
    mutate(
      produce(data, (draft) => {
        if (data && draft) {
          for (const d in data) {
            const idx = data[d].data.findIndex(
              (c) => c.chatId === `chat#${chatId}`
            );

            if (idx > -1) {
              draft[d].data[idx].title = title;
              break;
            }
          }
        }
      }),
      {
        revalidate: false,
      }
    );

    return updateTitle(chatId, title).finally(() => {
      mutate();
    });
  };

  const getChatTitle = (chatId: string) => {
    const idx =
      chats.findIndex((c: Chat) => c.chatId === `chat#${chatId}`) ?? -1;

    if (idx > -1) {
      return chats[idx].title;
    } else {
      return null;
    }
  };

  return {
    loading: isLoading,
    chats,
    mutate,
    updateChatTitle,
    deleteChat,
    getChatTitle,
    canLoadMore,
    loadMore,
  };
};

export default useChatList;
```

- サブ Hook の実行
  - useChatAPI の listChats、deleteChat、updateTitle を取得
  - usePagination
-

##### useChatApi フック

useChatList の中で useChatApi を呼び出しています。

```typescript
const { listChats, deleteChat: deleteChatApi, updateTitle } = useChatApi();
```

useChatApi フックはカスタムフックであり、実体は[packages/web/src/hooks/useChatApi.ts](https://github.com/aws-samples/generative-ai-use-cases/blob/a255682938ab24f6482f5d1f72795c076d0a2496/packages/web/src/hooks/useChatApi.ts) です。

```typescript:
import {
  PredictRequest,
  PredictResponse,
  CreateChatResponse,
  CreateMessagesRequest,
  CreateMessagesResponse,
  ListChatsResponse,
  ListMessagesResponse,
  PredictTitleRequest,
  PredictTitleResponse,
  FindChatByIdResponse,
  UpdateFeedbackRequest,
  UpdateFeedbackResponse,
  UpdateTitleRequest,
  UpdateTitleResponse,
  WebTextRequest,
  WebTextResponse,
  CreateShareIdResponse,
  FindShareIdResponse,
  GetSharedChatResponse,
} from 'generative-ai-use-cases-jp';
import {
  LambdaClient,
  InvokeWithResponseStreamCommand,
} from '@aws-sdk/client-lambda';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-provider-cognito-identity';
import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
import useHttp from '../hooks/useHttp';
import { decomposeId } from '../utils/ChatUtils';
import { AxiosResponse } from 'axios';
import { fetchAuthSession } from 'aws-amplify/auth';

const useChatApi = () => {
  const http = useHttp();

  return {
    createChat: async (): Promise<CreateChatResponse> => {
      const res = await http.post('chats', {});
      return res.data;
    },
    createMessages: async (
      _chatId: string,
      req: CreateMessagesRequest
    ): Promise<CreateMessagesResponse> => {
      const chatId = decomposeId(_chatId);
      const res = await http.post(`chats/${chatId}/messages`, req);
      return res.data;
    },
    deleteChat: async (chatId: string) => {
      return http.delete<void>(`chats/${chatId}`);
    },
    listChats: () => {
      const getKey = (
        pageIndex: number,
        previousPageData: ListChatsResponse
      ) => {
        if (previousPageData && !previousPageData.lastEvaluatedKey) return null;
        if (pageIndex === 0) return 'chats';
        return `chats?exclusiveStartKey=${previousPageData.lastEvaluatedKey}`;
      };
      return http.getPagination<ListChatsResponse>(getKey, {
        revalidateIfStale: false,
      });
    },
    findChatById: (chatId?: string) => {
      return http.get<FindChatByIdResponse>(chatId ? `chats/${chatId}` : null);
    },
    listMessages: (chatId?: string) => {
      return http.get<ListMessagesResponse>(
        chatId ? `chats/${chatId}/messages` : null
      );
    },
    updateTitle: async (chatId: string, title: string) => {
      const req: UpdateTitleRequest = {
        title,
      };
      const res = await http.put<UpdateTitleResponse>(
        `chats/${chatId}/title`,
        req
      );
      return res.data;
    },
    updateFeedback: async (
      _chatId: string,
      req: UpdateFeedbackRequest
    ): Promise<UpdateFeedbackResponse> => {
      const chatId = decomposeId(_chatId);
      const res = await http.post(`chats/${chatId}/feedbacks`, req);
      return res.data;
    },
    // Buffered Response (useTextToJson で利用)
    predict: async (req: PredictRequest): Promise<string> => {
      const res = await http.post<PredictResponse>('predict', req);
      return res.data;
    },
    // Streaming Response
    predictStream: async function* (req: PredictRequest) {
      const token = (await fetchAuthSession()).tokens?.idToken?.toString();
      if (!token) {
        throw new Error('認証されていません。');
      }

      const region = import.meta.env.VITE_APP_REGION;
      const userPoolId = import.meta.env.VITE_APP_USER_POOL_ID;
      const idPoolId = import.meta.env.VITE_APP_IDENTITY_POOL_ID;
      const cognito = new CognitoIdentityClient({ region });
      const providerName = `cognito-idp.${region}.amazonaws.com/${userPoolId}`;
      const lambda = new LambdaClient({
        region,
        credentials: fromCognitoIdentityPool({
          client: cognito,
          identityPoolId: idPoolId,
          logins: {
            [providerName]: token,
          },
        }),
      });

      // Append idToken to req
      req.idToken = token;

      const res = await lambda.send(
        new InvokeWithResponseStreamCommand({
          FunctionName: import.meta.env.VITE_APP_PREDICT_STREAM_FUNCTION_ARN,
          Payload: JSON.stringify(req),
        })
      );
      const events = res.EventStream!;

      for await (const event of events) {
        if (event.PayloadChunk) {
          yield new TextDecoder('utf-8').decode(event.PayloadChunk.Payload);
        }

        if (event.InvokeComplete) {
          break;
        }
      }
    },
    predictTitle: async (
      req: PredictTitleRequest
    ): Promise<PredictTitleResponse> => {
      const res = await http.post('predict/title', req);
      return res.data;
    },
    getWebText: async (
      req: WebTextRequest
    ): Promise<AxiosResponse<WebTextResponse>> => {
      return await http.api.get(`web-text?url=${req.url}`);
    },
    createShareId: async (
      chatId: string
    ): Promise<AxiosResponse<CreateShareIdResponse>> => {
      const res = await http.post(`shares/chat/${chatId}`, {});
      return res.data;
    },
    findShareId: (chatId?: string) => {
      return http.get<FindShareIdResponse>(
        chatId ? `/shares/chat/${chatId}` : null
      );
    },
    getSharedChat: (shareId: string) => {
      return http.get<GetSharedChatResponse>(`/shares/share/${shareId}`);
    },
    deleteShareId: (shareId: string) => {
      return http.delete<void>(`/shares/share/${shareId}`);
    },
  };
};

export default useChatApi;
```

useChatApi はチャット用の API 呼び出しメソッドをまとめた Hook です。
以下の関数を用意しています。

- **createChat** メソッド: `/chats` (POST) => [`createChatFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#createchatfunction)
- **createMessages** メソッド: `/chats/{chatId}/messages` (POST) => [`createMessagesFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#createmessagesfunction)
- **deleteChat** メソッド: `/chats/{chatId}` (DELETE) => [`deleteChatFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#deletechatfunction)
- **listChats** メソッド: `/chats` (GET) => [`listChatsFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#listchatsfunction)
- **findChatById** メソッド: `/chats/{chatId}` (GET) => [`findChatbyIdFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#findchatbyidfunction)
- **listMessages** メソッド: `/chats/{chatId}/messages` (GET) => [`listMessagesFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#listmessagesfunction)
- **updateTitle** メソッド: `/chats/{chatId}/title` (PUT) => [`updateChatTitleFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#updatechattitlefunction)
- **updateFeedback** メソッド: `/chats/{chatId}/feedbacks` (POST) => [`updateFeedbackFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#updatefeedbackfunction)
- **predict** メソッド: `/predict` (POST) => [`predictFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#predictfunction)
- **predictStream** メソッド: (API でなく SDK 経由で呼び出し) => [`predictStreamFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#predictstreamfunction)
- **predictTitle** メソッド: `/predict/title` (POST) => [`predictTitleFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#predicttitlefunction)
- **getWebText** メソッド: `/web-text` (GET) => [`getWebTextFunction`](https://qiita.com/siruko/items/5f249f11847f3829c18a#getwebtextfunction)
- **createShareId** メソッド: `/shares/chat/{chatId}` (POST) => [`createShareId`](https://qiita.com/siruko/items/5f249f11847f3829c18a#createshareid)
- **findShareId** メソッド: `/shares/chat/{chatId}` (GET) => [`findShareId`](https://qiita.com/siruko/items/5f249f11847f3829c18a#findshareid)
- **getSharedChat** メソッド: `/shares/share/{shareId}` (GET) => [`getSharedChat`](https://qiita.com/siruko/items/5f249f11847f3829c18a#getsharedchat)
- **deleteShareId** メソッド: `/shares/share/{shareId}` (DELETE) => [`deleteShareId`](https://qiita.com/siruko/items/5f249f11847f3829c18a#deleteshareid)

##### usePagination フック

useChatList の中で usePagination を呼び出しています。

```typescript
const {
  data,
  flattenData: chats,
  mutate,
  isLoading,
  canLoadMore,
  loadMore,
} = usePagination(listChats(), 100);
```

usePagination フックはカスタムフックであり、実体は[packages/web/src/hooks/usePagination.ts](https://github.com/aws-samples/generative-ai-use-cases/blob/a255682938ab24f6482f5d1f72795c076d0a2496/packages/web/src/hooks/usePagination.ts) です。

```typescript:
import { SWRInfiniteResponse } from 'swr/infinite';
import { Pagination } from 'generative-ai-use-cases-jp';

const usePagination = (
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  swr: SWRInfiniteResponse<Pagination<any>>,
  pageSize: number
) => {
  const { data, size, setSize, error, mutate, isValidating } = swr;
  const flattenData = data
    ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
      data.map((d: Pagination<any>) => d.data).flat()
    : [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore =
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.data?.length === 0;
  const isReachingEnd =
    isEmpty ||
    (data &&
      (data[data.length - 1]?.data.length < pageSize ||
        !data[data.length - 1]?.lastEvaluatedKey));
  const canLoadMore = !isReachingEnd;

  return {
    data,
    flattenData,
    mutate,
    isLoading: isLoadingMore,
    isReachingEnd,
    isValidating,
    canLoadMore,
    loadMore: () => {
      setSize(size + 1);
    },
  };
};

export default usePagination;
```

usePagination は無限スクロール用の Hook です。
useChatApi().listChats 関数の戻り値を元に、以下の値を返しています。

- **data**: リストデータ (2 次元)
- **flattenData**: リストデータ (1 次元)
- **mutate**: キャッシュを手動で更新する関数
- **isLoading**: 次のページのデータをロード中かを表す boolean 値
- **isReachingEnd**: 最終データまで到達したかを表す boolean 値
- **isValidating**: データ取得中かどうかを表す boolean 値 (true が取得中)
- **canLoadMore**: ロードデータがまだあるかを表す boolean 値 (isReachingEnd の逆)
- **loadMore**: 次のページをロードする関数

今回は以上です。
次回もページごとの詳細仕様を解説していきます。

## (参考) GenU のバックエンド (CDK) 詳細解説投稿一覧

- [①AWS CDK のセットアップ](https://qiita.com/siruko/items/fd25fdcf89615cb85262)
- [②AWS CDK の動作確認](https://qiita.com/siruko/items/73169f986b4173e3d3a5)
- [③GenU の概要](https://qiita.com/siruko/items/625801b9e1847b305c1e)
- [④GenU CDK スタックの概要](https://qiita.com/siruko/items/8570ed43f7162ea4b907)
- [⑤CloudFrontWafStack スタックの解説](https://qiita.com/siruko/items/30439576ee7c63165d21)
- [⑥RagKnowledgeBaseStack スタックの解説](https://qiita.com/siruko/items/1223c9d22e73168a8809)
- [⑦WebSearchAgentStack スタックの解説](https://qiita.com/siruko/items/aef0a9599df60d47eb1e)
- [⑧GuardrailStack スタックの解説](https://qiita.com/siruko/items/d16e9fe27df4673d7554)
- [⑨GenerativeAiUseCasesStack > Auth スタックの解説](https://qiita.com/siruko/items/0c14040a1af132f42382)
- [⑩GenerativeAiUseCasesStack > Database, Api スタックの解説](https://qiita.com/siruko/items/5f249f11847f3829c18a)
- [⑪GenerativeAiUseCasesStack > CommonWebAcl, Web, Rag スタックの解説](https://qiita.com/siruko/items/1063daa2efe0b374a3fc)
- [⑫GenerativeAiUseCasesStack > RagKnowledgeBase, UseCaseBuilder, Transcribe スタックの解説](https://qiita.com/siruko/items/73d6006004caf8648594)
- [⑬DashBoard スタックの解説](https://qiita.com/siruko/items/73a75c7d0146d12ecbc3)
- [⑭GenU の Outputs の解説](https://qiita.com/siruko/items/afc14128a5b1a15ab69c)

## (参考) GenU のデプロイオプション詳細解説投稿一覧

- [GenU のデプロイオプション詳細解説](https://qiita.com/siruko/items/49c43862007603f3f70c)