こんにちは、広野です。
生成 AI 界隈の技術の進化がすさまじく、以前開発したチャットボットのアーキテクチャも陳腐化が見えてきました。この記事を執筆している時点での最新のアーキテクチャで改めて作り直してみたので、いくつかの記事に分けて紹介します。
今回 (4回目) は実装編 その3 React アプリ開発編です。
前回の記事
アーキテクチャ概要については前回記事で紹介しています。こちらをご覧ください。
バックエンド実装については以下です。


今回の説明範囲
本記事では、以下の UI コード (React) を説明します。前回記事の Amazon Bedrock 問い合わせ用 API ができていることが前提となります。
サマリー
重要なポイントは、AWS AppSync Events API をサブスクライブすることです。それによりリアルタイムに Amazon Bedrock からのストリームレスポンスを画面に表示できます。
AWS の公式ドキュメントでは、以下にリファレンスがあります。基本的にはこれに従っています。
React コード (抜粋)
App.jsx
App.jsx など上位のソースファイルに以下のように Amazon Cognito と AWS AppSync Events リソースと連携するための設定を書きます。すみません、私のコードでは環境変数を使っています。
import { Amplify } from 'aws-amplify'; import { Authenticator, useAuthenticator } from '@aws-amplify/ui-react'; //Cognito, AppSync Events 連携設定 Amplify.configure({ Auth: { Cognito: { userPoolId: import.meta.env.VITE_USERPOOLID, userPoolClientId: import.meta.env.VITE_USERPOOLWEBCLIENTID, identityPoolId: import.meta.env.VITE_IDPOOLID } }, API: { Events: { endpoint: import.meta.env.VITE_APPSYNCEVENTSHTTPENDPOINT, region: import.meta.env.VITE_REGION, defaultAuthMode: 'userPool' }, }, });
チャットボット画面
import { useState, useEffect, useRef } from "react"; import { Container, Grid, Box, Paper, Typography, TextField, Button, Avatar } from "@mui/material"; import SendIcon from '@mui/icons-material/Send'; import { blue, grey } from '@mui/material/colors'; import { v4 as uuidv4 } from "uuid"; import { events } from "aws-amplify/data"; import { inquireBedrockSr, Markdown } from "./Functions.jsx"; import Header from "../Header.jsx"; import Menu from "./Menu.jsx"; const BedrockSr = (props) => { //定数定義 const groups = props.groups; const sub = props.sub; const idtoken = props.idtoken; const imgUrl = import.meta.env.VITE_IMG_URL; //変数定義 const sessionidRef = useRef(); const channelRef = useRef(); //state定義 const [prompt, setPrompt] = useState(""); const [response, setResponse] = useState(""); const [conversation, setConversation] = useState([]); //Bedrockへの問い合わせ送信関数 const putBedrockSr = () => { //前のレスポンスがあれば配列にappendしてクリア if (response) { setConversation((prev) => [ ...prev, { role: "ai", text: response, } ]); setResponse(""); } setConversation((prev) => [ ...prev, { role: "user", text: prompt, } ]); inquireBedrockSr(prompt, sessionidRef.current, idtoken); //プロンプト欄をクリア setPrompt(""); }; //サブスクリプション開始関数 const startSubscription = async () => { const sessionid = sessionidRef.current; if (channelRef.current) await channelRef.current.close(); const channel = await events.connect(`bedrock-stream-response/${sub}/${sessionid}`); channel.subscribe({ next: (data) => { setResponse((prev) => prev + data.event.message); }, error: (err) => console.error("Subscription error:", err), complete: () => { setConversation((prev) => [ ...prev, { role: "ai", text: response, }, ]); setResponse(""); } }); channelRef.current = channel; }; //セッションIDのリセット、サブスクリプション再接続関数 const resetSession = async () => { sessionidRef.current = uuidv4(); //表示中のプロンプト、会話履歴、レスポンスをリセット setPrompt(""); setResponse(""); setConversation([]); await startSubscription(); }; //画面表示時 useEffect(() => { //画面表示時に最上部にスクロール window.scrollTo(0, 0); //Bedrockからのレスポンスサブスクライブ関数実行 sessionidRef.current = uuidv4(); startSubscription(); //アンマウント時にチャンネルを閉じる return () => { if (channelRef.current) channelRef.current.close(); }; }, []); //Chatbot UI 会話部分 const renderMessage = (msg, idx) => ( <Box key={idx} sx={{ display: "flex", justifyContent: msg.role === "user" ? "flex-end" : "flex-start", mb: 1, width: "100%", }} > {msg.role === "ai" && ( <Avatar src={`${imgUrl}/images/ai_chat_icon.svg`} alt="AI" sx={{ mr: 2 }} /> )} <Paper elevation={2} sx={{ p: 2, my: 1, maxWidth: "100%", bgcolor: msg.role === "user" ? blue[100] : grey[100], }} > <Markdown>{msg.text}</Markdown> </Paper> {msg.role === "user" && ( <Avatar src={`${imgUrl}/images/human_chat_icon.svg`} alt="User" sx={{ ml: 2 }} /> )} </Box> ); return ( <> {/* Header */} <Header groups={groups} signOut={props.signOut} /> <Container maxWidth="lg" sx={{mt:2}}> <Grid container spacing={4}> {/* Menu Pane */} <Grid size={{xs:12,md:4}} order={{xs:2,md:1}}> {/* Sidebar */} <Menu /> </Grid> {/* Contents Pane IMPORTANT */} <Grid size={{xs:12,md:8}} order={{xs:1,md:2}} my={2}> <main> <Grid container spacing={2}> {/* Heading */} <Grid size={{xs:12}}> <Typography id="bedrocksrtop" variant="h5" component="h1" mb={3} gutterBottom>Amazon Bedrock Stream Response テスト</Typography> </Grid> <Grid size={{xs:12}}> {/* Chatbot */} <Paper sx={{p:2,mb:2,width:"100%"}}> {/* あいさつ文(固定) */} {renderMessage({ role: "ai", text: "こんにちは。何かお困りですか?" }, -1)} {/* 会話部分 */} {conversation.map((msg, idx) => renderMessage(msg, idx))} {/* 直近のレスポンス */} {response && renderMessage({ role: "ai", text: response }, "stream") } </Paper> {/* 入力エリア */} <Box sx={{display:"flex",gap:1}}> <TextField fullWidth multiline value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Type message here..." sx={{ flexGrow: 1 }} /> <Button variant="contained" size="small" onClick={putBedrockSr} disabled={!prompt} startIcon={<SendIcon />} sx={{ whiteSpace: "nowrap", flexShrink: 0 }}>送信</Button> </Box> {/* クリアボタン */} {(response || conversation.length > 0) && ( <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}> <Button variant="contained" size="small" onClick={resetSession}>問い合わせをクリアする</Button> </Box> )} </Grid> </Grid> </main> </Grid> </Grid> </Container> </> ); }; export default BedrockSr;
解説
ステートは以下を使用しています。
- prompt:ユーザー入力中のテキスト (プロンプト)
- response:Amazon Bedrockから受信中のストリームレスポンス
- conversation:会話履歴(ユーザーと AI の両方)
以下は useRef を使用しています。問い合わせセッションが変わるたびにリセットされる値なのですが、リセットの度に再レンダーされないようにするためです。
- sessionidRef:チャットセッション ID(UUIDで生成)
- channelRef:サブスクライブ中の AWS AppSync Events チャンネル
サブスクリプションの部分は、新しいメッセージを受信すると setResponse で response ステートを更新し、その都度再レンダーがかかる仕様になっています。
Amazon Bedrock への問い合わせは関数化しており、その中身は以下になっています。単純に axios で Amazon API Gateway REST API に POST しているだけです。
//Bedrock問い合わせ関数 import axios from 'axios'; const inquireBedrockSr = async (prompt, sessionid, idtoken) => { const res = await axios.post( import.meta.env.VITE_RESTAPIENDPOINTBEDROCKSR, { prompt: prompt, sessionid: sessionid }, { headers: { "Authorization": idtoken } } ); return res; };
今回、基盤モデルとして Amazon Nova micro を使用していますが、この場合レスポンスがマークダウン形式で戻ってきます。それを適切に HTML 化して表現したかったので、Markdown 関数を使用して変換をかけています。
import ReactMarkdown from 'markdown-to-jsx'; import { Typography, Link, Box } from '@mui/material'; //Markdown関数 const Markdown = (props) => { const MarkdownListItem = (props) => { return <Box component="li" sx={{ mt: 1, typography: 'body1' }} {...props} />; }; const options = { overrides: { h1: { component: Typography, props: { gutterBottom: true, variant: 'h4', component: 'h1' } }, h2: { component: Typography, props: { gutterBottom: true, variant: 'h5', component: 'h2' } }, h3: { component: Typography, props: { gutterBottom: true, variant: 'h6', component: 'h2' } }, h4: { component: Typography, props: { gutterBottom: true, variant: 'subtitle1', paragraph: true } }, p: { component: Typography, props: { paragraph: true, className: 'txt' } }, a: { component: Link, props: { rel: "noopener", target: "_blank" } }, li: { component: MarkdownListItem } } }; return <ReactMarkdown options={options} {...props} />; };
まとめ
いかがでしたでしょうか。
React コードの解説は難しかったので少なくなってしまいました。実際に動いたコードを見て参考にして頂けたらと思います。
本記事が皆様のお役に立てれば幸いです。