前提条件
-
インフラ構築ばかりやってるのでフロントエンドの知見がほぼ無い状態。
フロントエンドの知識を学習するにあたり、以下を参照して理解を深めました。
【図解解説】0からReact開発して基礎をマスターできる最強チュートリアル 映画アプリ編【初心者完全版】 - Qiita -
とりあえず個人で使えれば良いので、その前提で検討を進める。
→セキュリティ的には若干緩めになる想定。 -
ひとまず動くところまでを目標に作ってみる。
-
フロントはReactを想定して構築を進める。
-
バックエンドはAWSのマネージドサービスを利用する。
-
Amazon BedrockのAPIをブラウザ上から呼び出すことを目的とするため、Bedrockの作り込みについては割愛する。
-
今回の記事では、BedrockおよびLambda等全てのAWSサービスについて「バージニア北部」を利用する。
その1 - アーキテクトについて考える
よくあるブラウザ上で動くチャットボットアプリケーションを作成したと思い立ったものの、どうやって作成していけば良いのか検討が付かず、調査してみました。
まずは、チャットボットをブラウザ上で動かすためのアーキテクトについて考えてみます。
フロントサイドから直接Bedrock APIを叩く検討をしてみる
結論から言うとNG。
- Bedrock を呼び出すには AWS SDK + 有効な IAM 認証情報(Bedrockへのアクセス権限 や Cognito などで取得した一時認証情報)が必要。
- もしフロントエンドに直接書き込むと、IAM 認証情報がブラウザに露出してしまい、利用者に簡単に盗まれるリスクがある。
→ 詰まるところ、技術的には可能だけどセキュリティ的にアウト。
どのような構成で検討を進めるか
ある程度インフラも自分で組み立てたいので、今回は以下のAWSマネージドサービスを活用したサーバレス構成で検討していきます(Amplifyとか使わない)。
フロントエンド:
- React SPA(Single Page Application)
- S3 + CloudFront でホスティング
- Amazon Cognito(認証。いずれやる。ここではやらない)
バックエンド:
- API Gateway (REST/WebSocket)
- Lambda関数
- DynamoDB (チャット履歴。いずれやる。ここではやらない)
その2 - バックエンドの作成
まずはバックエンドから作成してみます。LambdaからBedrockの呼び出しをしてみて、レスポンスが返ってくることを確認します。
Lambda用のIAMロール作成
Bedrock呼び出し用にIAMロールを作成する。今回はAmazonBedrockFullAccess
を設定している。
また、LambdaからCloudWatch Logsにログを送信したいため、CloudWatchFullAccess
も設定している。
Lambda関数の作成
先ほど作成したIAMロールをアタッチしたLambda関数を作成します。
- ランタイム:Python 3.13
- アーキテクチャ:x86_64
- タイムアウト:5分(デフォの3秒だと生成AIからの回答を待っている間にタイムアウトになっちゃうので今回は5分に値にしておく)
BedrockのAPI呼び出しについては、以下公式サイトの内容を参考にしました。
Bedrock の Converse API とレスポンスストリームを使用して Amazon Bedrock で Anthropic Claude を呼び出す - Amazon Bedrock
import json
import boto3
from botocore.exceptions import ClientError
def lambda_handler(event, context):
# Bedrockクライアントの作成
bedrock_runtime = boto3.client('bedrock-runtime')
# モデルIDの指定
model_id = "anthropic.claude-3-5-sonnet-20240620-v1:0"
# ユーザプロンプト
user_message = event.get('userMessage')
conversation = [
{"role": "user",
"content": [{"text": user_message}]}
]
response_text = ""
try:
# メッセージをモデルに送信する
streaming_response = bedrock_runtime.converse_stream(
modelId=model_id,
messages=conversation,
inferenceConfig={
"maxTokens": 4096,
"temperature": 0.5,
"topP": 0.5,
}
)
# ストリームされた応答テキストをリアルタイムで抽出して出力する。
for chunk in streaming_response["stream"]:
if "contentBlockDelta" in chunk:
text = chunk["contentBlockDelta"]["delta"]["text"]
response_text += text
except (ClientError, Exception) as e:
return {
'statusCode': 500,
'body': json.dumps({'error': f"Can't invoke '{model_id}'. Reason: {str(e)}"})
}
# API Gatewayが期待する形式でレスポンスを返す
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json', # レスポンスボディのデータ形式(今回はJSON)を指定
'Access-Control-Allow-Origin': '*', # CORS(Cross-Origin Resource Sharing)対応
'Access-Control-Allow-Methods': 'POST, OPTIONS', # 許可するHTTPメソッドを指定
'Access-Control-Allow-Headers': 'Content-Type' # クライアントが送信可能なヘッダーを指定(Content-Typeはリクエストボディの形式指定を許可)
},
'body':response_text
}
Lambda関数のテストを実行してみます。
テストコードは以下を入力します。
{
"userMessage":"虹が七色に輝くのはなぜですか"
}
問題なくテストも成功しました(改行コードが入ってるけど画面表示するときには消えるんで一旦無視)。
API Gatewayの作成
インターネットからアクセスが可能となるよう、API Gatewayを作成してAPIとしてBedrockからの回答をユーザに受け渡しできるようにします。
API Gatewayの構築にあたり、以下のサイトが大変参考になりました。
Amazon Bedrock KnowledgeBase のAPIをLambda と API Gatewayで利用してみた
API Gatewayは、REST APIで構築していきます。
新しくAPIを作成完了したら、リソースを作成していきます。
※CORSにはチェックを入れておきます。
リソースが作成されたら、メソッドを作成していきます。
メソッドの詳細は以下としました。
- メソッドタイプ:POST
- 統合タイプ:Lambda関数
- Lambda関数:us-east-1 / 事前に作成したLambda関数を指定
※他の項目はデフォルトのまま。
※既存で作成してある OPTIONS メソッドは、主にクロスオリジンリソース共有(CORS)をサポートするために使用されるHTTPリクエストメソッド。
続いて、作成したPOSTメソッドの「メソッドレスポンス」を編集していきます。
クライアントに返すレスポンスの形式(HTTPステータスコード、ヘッダー、本文)を定義する設定です。
ヘッダー名に「Access-Control-Allow-Origin」を追加して、CORS(Cross-Origin Resource Sharing)対応を追加します。
次に作成したPOSTメソッドの「統合レスポンス」を設定していきます。
API Gateway とバックエンドサービス(Lambda関数など)の間の統合設定において、バックエンドからのレスポンスをクライアントに返すように加工・変換するための設定項目です。
ヘッダーのマッピング > マッピングの値 に「'*’」を追加します。
この設定では、Lambda関数が返すすべてのステータスコード(200, 400, 500など)を統一的に処理することを表しており、要はLambda側でエラーコードを完全制御するって意味で捉えておけばOKだと思ってます。
次にOPTIONSメソッドの「統合リクエスト」を設定します。
API Gateway がクライアントから受け取ったリクエストを、定義されたバックエンド(例:AWS Lambda、DynamoDB、HTTPエンドポイントなど)へ渡す際に、リクエストの形式や内容を変換する設定らしいです。
以下値を入力し、「保存」ボタンをクリックします。
- 統合タイプ:Lambda関数
- Lambda関数: us-east-1 / 事前に作成したLambda関数
最後にOPTIONSメソッドの「統合レスポンス」を設定します。
以下値を入力し、「保存」ボタンをクリックします。
- Access-Control-Allow-Headers: 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token’
- 各ヘッダーの意図は以下(Amazon Qに聞いてみた内容です)
- Content-Type: JSONリクエストボディ用
- X-Amz-Date: AWS署名認証用のタイムスタンプ
- Authorization: AWS署名認証ヘッダー
- X-Api-Key: API Gateway APIキー認証用
- X-Amz-Security-Token: AWS一時認証情報用
- 各ヘッダーの意図は以下(Amazon Qに聞いてみた内容です)
- Access-Control-Allow-Methods: 'OPTIONS,POST’
- Access-Control-Allow-Origin: '*’
上記までの設定が完了したら「APIをデプロイ」をクリックします。
ステージは「新しいステージ」として、名前は任意でOKです。
API Gateway→Lambdaの動作確認
API Gateway>ステージ>OPTIONSをクリックして、表示されたURLをメモしておきます。
CloudShellを開いて以下のコマンドを入力し、Lambda関数から回答があれば動作確認OKです(ローカルPCのターミナルからでも確認できます)。
curl -X POST -H "Content-Type: application/json" -d '{"userMessage": "***<質問文>***"}' ***<APIのURL>***
その3 - フロントエンドの作成
さて、ここからはユーザが質問を入力するフロントエンドのページを作成していきます。前提に記載したように、Reactを使ってコンテンツを作成して、CloudFront + S3でホスティングしたいと思います。
Reactプロジェクトを立ち上げる
まずはローカルPC上で作業用のフォルダ(名前は適当)を作成し、そのフォルダに移動します。
mkdir dev && cd dev
Vite+Reactのプロジェクトを作成します。
npm create vite@latest
Need to install the following packages:
create-vite@8.0.2
Ok to proceed? (y) y
> npx
> create-vite
│
◇ Project name:
│ chatbot
│
◇ Select a framework:
│ React
│
◇ Select a variant:
│ TypeScript
│
◇ Use rolldown-vite (Experimental)?:
│ Yes
│
◆ Install with npm and start now?
│ Yes
プロジェクトができたらViteサーバを起動して動作確認をします。
cd chatbot
npm install
npm run dev
ROLLDOWN-VITE v7.1.14 ready in 573 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
http://localhost:5173/ を開いて以下の画面が出ればOKです。
ReactでSPAを作っていく
ページの中身はAmazon Qにほぼ作成してもらいました。AI様様ですね。
App.tsx が以下になります。
AIに作ってもらったとはいえ、中身の理解はしたいので、コメント記載しまくってもらってます。
import { useState } from 'react'
import './App.css'
// メッセージオブジェクトの型定義
interface Message {
id: number // メッセージの一意識別子(重複防止)
text: string // メッセージの内容
isUser: boolean // ユーザーメッセージ(true) or ボットメッセージ(false)
}
function App() {
const [messages, setMessages] = useState<Message[]>([]) // 状態管理: チャット履歴を格納する配列
const [input, setInput] = useState('') // 状態管理: 入力フィールドの現在値
const [loading, setLoading] = useState(false) // 状態管理: API呼び出し中かどうかの状態
// メッセージ送信処理(非同期関数)
const sendMessage = async () => {
// 空文字や空白のみの入力をチェック
if (!input.trim()) return
// ユーザーメッセージオブジェクトを作成
const userMessage: Message = {
id: Date.now(), // 現在時刻をIDとして使用(簡易的な一意性確保)
text: input, // 入力されたテキスト
isUser: true // ユーザーメッセージフラグ
}
// メッセージ配列にユーザーメッセージを追加(スプレッド演算子で既存配列を展開)
setMessages(prev => [...prev, userMessage])
// 入力値を保存(API呼び出し用)
const userInput = input
// 入力フィールドをクリア
setInput('')
// ローディング状態を開始
setLoading(true)
try {
// AWS API GatewayエンドポイントへのHTTP POSTリクエスト
const response = await fetch('<API GatewayのURL※適宜変更してください>', {
method: 'POST', // HTTPメソッド
headers: {
'Content-Type': 'application/json' // リクエストヘッダー
},
body: JSON.stringify({ // JavaScriptオブジェクトをJSON文字列に変換
userMessage: userInput // Lambda関数が期待するパラメータ名
})
})
// レスポンスをJSONとして解析
const data = await response.json()
console.log(data)
// ボットメッセージオブジェクトを作成
const botMessage: Message = {
id: Date.now() + 1, // ユーザーメッセージと重複しないよう+1
text: data['body'] || 'レスポンスを取得できませんでした。', // APIレスポンスまたはデフォルトメッセージ
isUser: false // ボットメッセージフラグ
}
// メッセージ配列にボットメッセージを追加
setMessages(prev => [...prev, botMessage])
} catch (error) {
// ネットワークエラーやAPI呼び出し失敗時の処理
const errorMessage: Message = {
id: Date.now() + 1,
text: 'エラーが発生しました。',
isUser: false
}
setMessages(prev => [...prev, errorMessage])
} finally {
// 成功・失敗に関わらず必ず実行される処理
setLoading(false) // ローディング状態を終了
}
}
return (
<div className="chat-container">
{/* ヘッダー部分 */}
<div className="chat-header">
<h1>自作チャットボット</h1>
</div>
{/* メッセージ表示エリア */}
<div className="chat-messages">
{/* メッセージ配列をマップしてレンダリング */}
{messages.map(message => (
<div
key={message.id}
className={`message ${message.isUser ? 'user' : 'bot'}`} // 条件付きクラス名
>
<div className="message-content">
{message.text}
</div>
</div>
))}
{/* ローディング中の表示(条件付きレンダリング) */}
{loading && (
<div className="message bot">
<div className="message-content">入力中...</div>
</div>
)}
</div>
{/* 入力エリア */}
<div className="chat-input">
<input
type="text"
value={input} // 制御されたコンポーネント
onChange={(e) => setInput(e.target.value)} // 入力値の状態更新
onKeyDown={(e) => e.key === 'Enter' && sendMessage()} // Enterキーでの送信
placeholder="メッセージを入力してください..."
disabled={loading} // ローディング中は入力無効
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim()} // ローディング中または空文字時は無効
>
送信
</button>
</div>
</div>
)
}
export default App
続いて、App.cssです。
ChatGPTみたいな感じで作って〜とAIにお任せしたら以下のようになりました。
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: light-dark(#ffffff, #1a1a1a);
color: light-dark(#333333, #ffffff);
}
.chat-header {
background: light-dark(#f5f5f5, #2d2d2d);
padding: 1rem;
border-bottom: 1px solid light-dark(#ddd, #444);
text-align: center;
}
.chat-header h1 {
margin: 0;
font-size: 1.5rem;
color: inherit;
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
background: light-dark(#fafafa, #1a1a1a);
}
.message {
margin-bottom: 1rem;
display: flex;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.message.user {
justify-content: flex-end;
}
.message.bot {
justify-content: flex-start;
}
.message-content {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 18px;
word-wrap: break-word;
}
.message.user .message-content {
background: #007bff;
color: white;
}
.message.bot .message-content {
background: light-dark(#e9ecef, #3a3a3a);
color: light-dark(#333, #ffffff);
}
.chat-input {
display: flex;
padding: 1rem;
border-top: 1px solid light-dark(#ddd, #444);
background: light-dark(#ffffff, #2d2d2d);
max-width: 800px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.chat-input input {
flex: 1;
padding: 0.75rem;
border: 1px solid light-dark(#ddd, #555);
border-radius: 20px;
outline: none;
margin-right: 0.5rem;
background: light-dark(#ffffff, #1a1a1a);
color: light-dark(#333, #ffffff);
}
.chat-input input:focus {
border-color: #007bff;
}
.chat-input button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
transition: background 0.2s;
}
.chat-input button:hover:not(:disabled) {
background: #0056b3;
}
.chat-input button:disabled {
background: light-dark(#ccc, #555);
cursor: not-allowed;
}
CloudFront + S3 でホスティングする
まずはコンテンツを格納するS3バケットを作成します。
バケット名は一意である必要があるため、被らないような名前をつけてあげてください。
続いてCloudFrontディストリビューションを作成していきます。
任意の名前をディストリビューション名にします。
作成したS3バケットをオリジンに指定します。
他はデフォルトでOKです。
作成されたディストリビューションを選択してオリジンタブを開きます。
オリジン(今回は先ほど選択したS3バケット)を選択して「編集」を開きます。
オリジンを編集ページに移動したら、画面中部にある「ポリシーをコピー」をクリックして、先ほど作成したS3バケットポリシーに貼り付けます。
CloudFrontディストリビューションの作成が完了したら、S3バケットにコンテンツをアップロードしていきます。
ローカルPCにあるプロジェクトフォルダ上で以下のコマンドを実行して、Reactプロジェクトをビルドします。
npm run build
> chatbot@0.0.0 build
> tsc -b && vite build
rolldown-vite v7.1.14 building for production...
✓ 18 modules transformed.
dist/index.html 0.45 kB │ gzip: 0.28 kB
dist/assets/react-CHdo91hT.svg 4.12 kB │ gzip: 2.06 kB
dist/assets/index-Bty4G45y.css 1.55 kB │ gzip: 0.74 kB
dist/assets/index-BFsaTynn.js 191.87 kB │ gzip: 60.59 kB
✓ built in 413ms
プロジェクト直下にdist
というフォルダが作成されるので、そのフォルダの中身をS3バケットにアップロードしてください。
アクセス確認
最後にブラウザを開いてアクセスの確認をしていきます。
ブラウザのアドレスバーに「https://[ディストリビューションドメイン名]/index.html」と入力してください。
URLの最後に/index.htmlを付けないとページが表示されないことに注意です。
※index.htmlを付けたくないのであれば、CloudFront Functionsを作成して対応する必要がありますがここでは設定しません。
正しく設定されていれば、S3にアップロードしたWebサイトが表示されます。
最後に
とりあえずということで、フロント全くやったことないインフラエンジニアがAIの力を借りながらチャットボットアプリを作成してみました。
会話の履歴を残してなかったり、認証機能をつけてなかったりと、本番環境等で使えるようなものではありませんが、「ああこうやって出来るんだ〜」ということだけでも知れたのでいい勉強になりました。