0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

皆さん、こんにちは。

私は業務でデータ利活用基盤を取り扱っていること、2024 AWS Japan Top Engineer に選出されたということから、AWS GenU およびそれに必要なデータ基盤の探求 (Snowflake, dbt, Iceberg, etc) に取り組む必要があると考えています。

本投稿では、GenU のバックエンドである CDK コードを詳細に解説します。
自身そして閲覧して頂いた皆様の GenU への理解が少しでも深まり、生成 AI の民主化につながっていければと考えています。

GenU とは

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

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

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

GenU ユースケースページ

GenU ユースケースページ (web/src/App.tsx) は GenU のメイン機能である各ユースケースの親ページとなります。

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

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 です。

App.tsx (抜粋)
import useDrawer from './hooks/useDrawer';
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 を利用しています。
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が取得できます。

const { pathname, search, hash } = useLocation();
useChatList フック

useChatList フックはカスタムフックであり、実体はpackages/web/src/hooks/useChatList.ts です。

App.tsx (抜粋)
import useChatList from './hooks/useChatList';
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 を呼び出しています。

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

useChatApi フックはカスタムフックであり、実体はpackages/web/src/hooks/useChatApi.ts です。

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 です。
以下の関数を用意しています。

usePagination フック

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

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

usePagination フックはカスタムフックであり、実体はpackages/web/src/hooks/usePagination.ts です。

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) 詳細解説投稿一覧

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?