React で Amazon Bedrock ベースの簡易生成 AI チャットボットをつくる [2025年7月版] 実装編3 React

こんにちは、広野です。

生成 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 コードの解説は難しかったので少なくなってしまいました。実際に動いたコードを見て参考にして頂けたらと思います。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWS は好きですが、それよりもバックエンド構築を簡単にしてくれたことに対する感謝の気持ちの方が強いです。
取得資格:AWS 認定は15資格、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2024 Japan AWS Top Engineer 受賞
2022 - 2024 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AI・MLAWSアプリケーション開発クラウドソリューション
シェアする
タイトルとURLをコピーしました