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?

インフラエンジニアがBedrockを呼び出すチャットアプリを作ってみた

Posted at

前提条件

  • インフラ構築ばかりやってるのでフロントエンドの知見がほぼ無い状態。
    フロントエンドの知識を学習するにあたり、以下を参照して理解を深めました。
    【図解解説】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とか使わない)。
1.png

フロントエンド:

  • 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も設定している。
2.png

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":"虹が七色に輝くのはなぜですか"
}

問題なくテストも成功しました(改行コードが入ってるけど画面表示するときには消えるんで一旦無視)。
3.png

API Gatewayの作成

インターネットからアクセスが可能となるよう、API Gatewayを作成してAPIとしてBedrockからの回答をユーザに受け渡しできるようにします。
API Gatewayの構築にあたり、以下のサイトが大変参考になりました。
Amazon Bedrock KnowledgeBase のAPIをLambda と API Gatewayで利用してみた

API Gatewayは、REST APIで構築していきます。
4.png
5.png

新しくAPIを作成完了したら、リソースを作成していきます。
※CORSにはチェックを入れておきます。
6.png
リソースが作成されたら、メソッドを作成していきます。
7.png
メソッドの詳細は以下としました。

  • メソッドタイプ:POST
  • 統合タイプ:Lambda関数
  • Lambda関数:us-east-1 / 事前に作成したLambda関数を指定

※他の項目はデフォルトのまま。
※既存で作成してある OPTIONS メソッドは、主にクロスオリジンリソース共有(CORS)をサポートするために使用されるHTTPリクエストメソッド。

続いて、作成したPOSTメソッドの「メソッドレスポンス」を編集していきます。

クライアントに返すレスポンスの形式(HTTPステータスコード、ヘッダー、本文)を定義する設定です。
8.png
ヘッダー名に「Access-Control-Allow-Origin」を追加して、CORS(Cross-Origin Resource Sharing)対応を追加します。
9.png

次に作成したPOSTメソッドの「統合レスポンス」を設定していきます。

API Gateway とバックエンドサービス(Lambda関数など)の間の統合設定において、バックエンドからのレスポンスをクライアントに返すように加工・変換するための設定項目です。
10.png

ヘッダーのマッピング > マッピングの値 に「'*’」を追加します。
この設定では、Lambda関数が返すすべてのステータスコード(200, 400, 500など)を統一的に処理することを表しており、要はLambda側でエラーコードを完全制御するって意味で捉えておけばOKだと思ってます。
11.png

次にOPTIONSメソッドの「統合リクエスト」を設定します。

API Gateway がクライアントから受け取ったリクエストを、定義されたバックエンド(例:AWS Lambda、DynamoDB、HTTPエンドポイントなど)へ渡す際に、リクエストの形式や内容を変換する設定らしいです。
12.png

以下値を入力し、「保存」ボタンをクリックします。

  • 統合タイプ:Lambda関数
  • Lambda関数: us-east-1 / 事前に作成したLambda関数

最後にOPTIONSメソッドの「統合レスポンス」を設定します。
13.png

以下値を入力し、「保存」ボタンをクリックします。

  • 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一時認証情報用
  • Access-Control-Allow-Methods: 'OPTIONS,POST’
  • Access-Control-Allow-Origin: '*’

上記までの設定が完了したら「APIをデプロイ」をクリックします。
ステージは「新しいステージ」として、名前は任意でOKです。
14.png

API Gateway→Lambdaの動作確認

API Gateway>ステージ>OPTIONSをクリックして、表示されたURLをメモしておきます。
15.png

CloudShellを開いて以下のコマンドを入力し、Lambda関数から回答があれば動作確認OKです(ローカルPCのターミナルからでも確認できます)。

curl -X POST -H "Content-Type: application/json" -d '{"userMessage": "***<質問文>***"}' ***<APIのURL>***

16.png

その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です。
17.png

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バケットを作成します。

バケット名は一意である必要があるため、被らないような名前をつけてあげてください。

バケット名の他はデフォルトでOKです。
18.png

続いてCloudFrontディストリビューションを作成していきます。
任意の名前をディストリビューション名にします。
19.png

作成したS3バケットをオリジンに指定します。
他はデフォルトでOKです。
20.png

今回はテスト用なのでWAFは無効にしておきます。
21.png

作成されたディストリビューションを選択してオリジンタブを開きます。
オリジン(今回は先ほど選択したS3バケット)を選択して「編集」を開きます。
22.png
オリジンを編集ページに移動したら、画面中部にある「ポリシーをコピー」をクリックして、先ほど作成したS3バケットポリシーに貼り付けます。
23.png

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サイトが表示されます。
24.png

最後に

とりあえずということで、フロント全くやったことないインフラエンジニアがAIの力を借りながらチャットボットアプリを作成してみました。

会話の履歴を残してなかったり、認証機能をつけてなかったりと、本番環境等で使えるようなものではありませんが、「ああこうやって出来るんだ〜」ということだけでも知れたのでいい勉強になりました。

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?