はじめに
皆さん、こんにちは。
私は業務でデータ利活用基盤を取り扱っていること、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 を見ていきましょう。
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 です。
import useDrawer from './hooks/useDrawer';
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 です。
import useChatList from './hooks/useChatList';
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 です。
以下の関数を用意しています。
-
createChat メソッド:
/chats
(POST) =>createChatFunction
-
createMessages メソッド:
/chats/{chatId}/messages
(POST) =>createMessagesFunction
-
deleteChat メソッド:
/chats/{chatId}
(DELETE) =>deleteChatFunction
-
listChats メソッド:
/chats
(GET) =>listChatsFunction
-
findChatById メソッド:
/chats/{chatId}
(GET) =>findChatbyIdFunction
-
listMessages メソッド:
/chats/{chatId}/messages
(GET) =>listMessagesFunction
-
updateTitle メソッド:
/chats/{chatId}/title
(PUT) =>updateChatTitleFunction
-
updateFeedback メソッド:
/chats/{chatId}/feedbacks
(POST) =>updateFeedbackFunction
-
predict メソッド:
/predict
(POST) =>predictFunction
-
predictStream メソッド: (API でなく SDK 経由で呼び出し) =>
predictStreamFunction
-
predictTitle メソッド:
/predict/title
(POST) =>predictTitleFunction
-
getWebText メソッド:
/web-text
(GET) =>getWebTextFunction
-
createShareId メソッド:
/shares/chat/{chatId}
(POST) =>createShareId
-
findShareId メソッド:
/shares/chat/{chatId}
(GET) =>findShareId
-
getSharedChat メソッド:
/shares/share/{shareId}
(GET) =>getSharedChat
-
deleteShareId メソッド:
/shares/share/{shareId}
(DELETE) =>deleteShareId
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) 詳細解説投稿一覧
- ①AWS CDK のセットアップ
- ②AWS CDK の動作確認
- ③GenU の概要
- ④GenU CDK スタックの概要
- ⑤CloudFrontWafStack スタックの解説
- ⑥RagKnowledgeBaseStack スタックの解説
- ⑦WebSearchAgentStack スタックの解説
- ⑧GuardrailStack スタックの解説
- ⑨GenerativeAiUseCasesStack > Auth スタックの解説
- ⑩GenerativeAiUseCasesStack > Database, Api スタックの解説
- ⑪GenerativeAiUseCasesStack > CommonWebAcl, Web, Rag スタックの解説
- ⑫GenerativeAiUseCasesStack > RagKnowledgeBase, UseCaseBuilder, Transcribe スタックの解説
- ⑬DashBoard スタックの解説
- ⑭GenU の Outputs の解説