見出し画像

Amazon Bedrockをアプリと繋げてみる ~ ローカル開発用フロントエンドの作成 ~

前回は「Amazon Bedrockをアプリと繋げてみる ~ ローカル開発用バックエンドの作成 ~」ということで、ローカル開発用のバックエンドの作成をするところまでをやってみました。

本記事ではローカル環境用のフロントエンドを作成し、バックエンドと繋いで実際にBedrockからの回答を画面に反映するところまでをやっていきます。

構成

構成としては以下の形です。

構成図

主要ライブラリ

Vite

バンドルツールとしてはViteを利用します。

起動の速さ、デフォルトでのTypeScritp対応、ホットリーロード、Reactなどのフロントエンドライブラリのテンプレート対応など、面倒だった部分をコマンド一発で、手軽に用意してくれるので、2年ほど前ぐらいからWebpackから乗り換えています。

Turbopackも来るかもしれないので、時間が出来たら試してみたいなと思っています。

React

フロントエンドのUIライブラリとしては、Reactを利用します。

Vue.jsの方はある程度触ったので、最近はReactを使って覚えるようにしています。

状態管理ライブラリとしては、zustandを利用してみます。最近知ったので使ってみているところです。

Amplify

フロントエンドでAWSと繋げるのであれば、Amplifyを利用するのが、かなり便利です。AWSではAmplifyというサービスがありますが、サービスを利用せずとも、JavaScriptのライブラリだけを利用することができるようになっています。

ただ、ドキュメントがAmplifyのサービスを使う前提で書かれているので、読み解く能力が少し必要かもしれません。そして最近v6に変わったようで、ドキュメントのページが大きく変わり、情報を見つけるのに少し苦労しました。早く最新のドキュメントの構成に慣れないとですね。

Cognitoで認証を行いたいので、Amplify UI(React)を導入して簡単に認証が出来るようにします。

Tailwind CSS

フロントのデザインライブラリとしては、Tailwind CSSを利用します。

OOCSS、BEMなど色々と学習したり、使ってみたりしましたが、規模が大きくなったり、想定できていなかった要件の対応などでCSS設計が崩れていくこともあり、最近はTailwind CSSの思想も有りだなと感じて、学習がてら使ってみています。

AWS SDK

Lambdaの呼び出しをしたいので利用します。

フロントエンドの作成

今回はバックエンドで作成したディレクトリと同階層にフロント用のディレクトリを作成する形で進めます、

インストールや設定

フロントエンド自体の細かい導入部分については、また別の記事で書いてみたいと思います。

Viteで雛形のプロジェクトを作成し、必要となるライブラリなどを入れていきます。

npm install aws-amplify @aws-amplify/ui-react @aws-sdk/client-lambda zustand immer

フロントエンドでのポイント

Amplifyの設定

Amplify.configure({
  Auth: {
    Cognito: {
      userPoolClientId: import.meta.env.VITE_APP_USER_POOL_CLIENT_ID,
      userPoolId: import.meta.env.VITE_APP_USER_POOL_ID,
      identityPoolId: import.meta.env.VITE_APP_IDENTITY_POOL_ID,
      loginWith: {
        email: true,
      },
    },
  },
})

Amplifyの設定としてCognitoの情報を設定します。書き方はv6になっています。v5までとは少し違うので注意が必要です。

import.meta.envについては、後ほど解説しますが、環境変数になります。

メッセージの表示部分

export function ChatMessage() {
  const messages: Message[] = useConversationStore(state => state.messages)

  return (
    <div className="h-[77vh] overflow-y-scroll">
      {messages.map(message => {
        const roleColor = roleColorClass(message.role)
        const texts = message.text.split('\n\n')

        return (
          <p key={message.id} className={`px-5 py-3 leading-8 ${roleColor}`}>
            {texts.map((text, i) => {
              return (
                <span key={`${message.id}-${i}`}>
                  {text}
                  <br />
                </span>
              )
            })}
          </p>
        )
      })}
    </div>
  )
}

表示としては、状態管理ライブラリから情報を取得して、UIを宣言的に書いていくだけです。

メッセージの送信部分

export function ChatForm({ className }: TextFormProps) {
  const [message, setMessage] = useState('')
  const [isSending, setIsSending] = useState(false)
  const post = useConversationStore(state => state.post)

  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    setMessage(event.target.value)
  }
  const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault()
    if (isSending) {
      return
    }
    setIsSending(true)
    post(message)
    setMessage('')
    setIsSending(false)
  }

  return (
    <div className={className}>
      <div className="">
        <div className="fixed inset-x-0 bottom-0 mx-auto max-w-screen-lg   bg-gradient-to-b from-transparent to-white">
          <div className="px-4">
            <textarea
              className="h-full w-full resize-none border border-gray-300 p-5 pl-3 pr-36"
              placeholder="Type something..."
              value={message}
              onChange={handleChange}
            />
            <button
              className="absolute bottom-3 right-8 rounded-sm bg-blue-300 px-10 py-2 text-white hover:bg-blue-400 active:bg-blue-500"
              onClick={handleSubmit}
            >
              送信
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

送信部分はボタンがクリックされたら画面の表示を制御しつつ、状態管理ライブラリにメッセージの送信を依頼します。

状態管理ライブラリ

interface ConversationStore {
  messages: Message[]
  post: (message: string) => Promise<void>
}

export const useConversationStore = create<ConversationStore>()((set, get) => ({
  messages: [],
  post: async message => {
         // 自分の入力したプロンプトをmessagesに追加して状態を更新する
    set(state => {
      const newMessages = produce(state.messages, draft => {
        const humanMessage: Message = { id: crypto.randomUUID(), role: 'human', text: message }
        return [...draft, humanMessage]
      })

      return { messages: newMessages }
    })

    // Bedrockに送る情報をmessagesから作成する
    const sendMessage = get().messages.reduce((acc, cur) => {
      if (cur.role === 'human') {
        return `${acc} Human: ${cur.text}\n\n Assistant: `
      } else {
        return `${acc} ${cur.text}\n\n`
      }
    }, '')

    // Bedrockにプロンプトを投げて、ストリームオブジェクトを受け取る
    const stream = predictStream({
      messages: sendMessage.trim(),
    })

    // ストリームオブジェクトから、情報を受け取って、messagesに追加して状態を更新する
    let answer = ''
    const currentMessages = get().messages
    for await (const chunk of stream) {
      answer += chunk
      set(() => {
        const newMessages = produce(currentMessages, draft => {
          const message: Message = { id: crypto.randomUUID(), role: 'assistant', text: answer }
          return [...draft, message]
        })

        return { messages: newMessages }
      })
    }
  },
}))

状態管理ライブラリは、zustandを使っています。messagesプロパティは、自分が入力したプロンプトやBedrockからの回答を入れます。この中に入っているデータが画面側に表示されるようになっています。

実際の処理やmessagesの更新を行うのがpostメソッドになっています。

messagesからBedrock (Claude)に送信するためのプロンプトを作成して、predictStream関数に渡しています。

predictStreamはジェネレータ関数として定義されているので、ジェネレータ関数の戻り値からBedrockの回答を受け取り、最新のmessagesを作り直して、状態を更新するというのを繰り返します。

※produceという関数は、immerというライブラリの関数で、データをイミューダブルに扱えるライブラリです。

Bedrockへの送信

interface PredictStreamRequest {
  messages: string
}

// ジェネレーター関数として定義する
export async function* predictStream(req: PredictStreamRequest): AsyncGenerator<string> {
  // Lambdaクライアントの作成、認証情報としてCognitoを利用する設定をする
    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 idToken = (await fetchAuthSession()).tokens?.idToken?.toString() ?? ''

  const lambda = new LambdaClient({
    region: import.meta.env.VITE_APP_REGION,
    credentials: fromCognitoIdentityPool({
      client: cognito,
      identityPoolId: idPoolId,
      logins: {
        [providerName]: idToken,
      },
    }),
  })

  // Lambda(Bedrock)を実行してBedrockからストリームを受け取る
  const res = await lambda.send(
    new InvokeWithResponseStreamCommand({
      FunctionName: import.meta.env.VITE_APP_PREDICT_STREAM_FUNCTION_ARN,
      Payload: JSON.stringify(req),
    }),
  )

   // Lambda(Bedrock)からストリームで回答を受け取ったら呼び出し側に返していく
  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
    }
  }
}

関数はジェネレータ関数として定義して、ストリームで受け取った値を返得せるように定義します。

Lambdaクライアントを生成する際には、Cognitoを認証情報をして使用するように指定しています。これにより認証されていなければLambdaを呼び出せないようになっています。

次に、AWS SDKでは、Lambdaストリーミングレスポンス用にInvokeWithResponseStreamCommandを用意してくれているので、こちらを利用してLambdaを実行します。

レスポンスとしてイベントストリームが返ってきているので、それを利用して呼び出し側にBedrockからの回答をストリーム形式で返していきます。

import.meta.env.VITE_APP_REGION

Amplifyの設定でも少し触れましたが、関数の最初に書かれている上記は環境変数になっていて、Viteでデフォルトで組み込まれている仕組みになります。内部的にはdotenvを使っています。
.envファイルを用意して、起動することで環境変数に値がセットされるようになっています。

以下は、CDKで作成したリソースの情報を設定します。前回は出力情報としてリージョン以外は指定しているので、コンソールの出力を見るか、CloudFormationから確認ができるようになっています。

VITE_APP_REGION=
VITE_APP_USER_POOL_CLIENT_ID=
VITE_APP_USER_POOL_ID=
VITE_APP_IDENTITY_POOL_ID=
VITE_APP_PREDICT_STREAM_FUNCTION_ARN=

あとは、UI周りを整えたり、制御を追加して完了です。

フロントエンドの動作確認

以下のコマンドを実行してフロントを起動します。

npm run dev
図1

http://localhost:5173/で起動されているので、アクセスします。Amplify UIの認証画面が表示されるので、Create Accountタブをクリックして、Email、Passwordを入力してCreate Accountボタンをクリックします。

図2

以下の画面に切り替わるので、先ほど入力したメールアドレス宛に届いている認証コードを入力してConfirmボタンをクリックします。

図3

登録が成功すると以下の画面にリダイレクトされます。

図4

試しに適当な質問を投げてみます。

図5

Bedrockからの回答が来て画面に無事に反映されました。実際に動かしてみるとストリーム形式で、どんどんと画面に回答が反映されていくのがわかります。

フロントエンドで、プロンプトの生成などをやっているので、その部分は少しだけ複雑になってしまいましたが、Bedrockを利用については、ライブラリを呼び出すだけで、簡単に利用が出来ることが分かりました。

まとめ

今回はローカル環境用のフロントエンドを作成し、バックエンドと繋いで実際にBedrockからの回答を画面に反映するところまでをやってみました。

次回はAWS上で動作する環境を作成してみたいと思います。

今回のフロントエンドも含めたソースの完全版は以下で確認ができます。

関連記事


この記事が気に入ったらサポートをしてみませんか?