8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vite + AWS Lambda + AWS Amplify + AgentCore で簡単なAIチャットアプリを作ってみる

Posted at

はじめに

先日、Next.jsとAmazon Bedrock Agent Coreを使って、簡単なAIチャットアプリを作ってみました。

ただ、前回の構成には以下のような課題(※個人の感想)があったので、別の構成を考えてみました。

  1. デプロイ環境がAWSとVecelに分かれてしまい2重管理
  2. Next.jsは機能豊富だけど、シンプルな構成にはオーバースペック

ということで今回は、以下のような構成で作り直してみました。

  • フロントエンド:Vite
  • バックエンド:AWS Lambda(URL経由でストリーミングレスポンス有効)

コードはこちら

アーキテクチャ

アーキテクチャは以下になります。

アーキテクチャ.png

ポイント

  1. すべてのコンポーネントをAWS上にデプロイ可能になった
    Agent Core以外の部分は、AWS Amplifyを使って完結できるようになりました。

  2. バックエンドはLambdaを採用
    前回はNext.jsのAPI Routesを使ってバックエンドAPIを構築していましたが、Viteにはその仕組みがないため、今回はAWS Lambdaを採用しました。
    コンテナでWebサーバーを立てる選択肢もありましたが、今「できるだけシンプルに」という方針で、Lambdaによる構成にしています。

やってみる

前提:利用環境

  • Windows11

Step0. 環境準備

詳細はこちら

uvのプロジェクトを初期化します

uv init agent
cd agent

Pythonの仮想環境を作成します (Python 3.12でバージョン指定)

uv venv --python 3.12

仮想環境を有効化します。

.\.venv\Scripts\Activate.ps1

ローカル実行用にAWS認証情報を環境変数に設定します。

$env:AWS_PROFILE="xxxx"

Step1. StrandAgentでエージェントを作る

エージェントを作成するため、strands-agentsをインストールします。

uv add strands-agents

StrandsAgentsのコードを実装します。モデルIDは任意のモデルを使用してください。

./agent/main.py
from strands import Agent
from strands.models import BedrockModel

MODEL_ID = "anthropic.claude-3-5-sonnet-20240620-v1:0"

agent = Agent(model=BedrockModel(model_id=MODEL_ID))

agent("Tell me about agentic AI")

Step2. エージェントをAgentCore Runtimeにデプロイする

bedrock-agentcore-starter-toolkitをインストールします。

uv add bedrock-agentcore-starter-toolkit

bedrock-agentcore-starter-toolkitは、いい感じにAWS環境のリソース作成や設定を行ってくれる便利なツールです

https://github.com/aws/bedrock-agentcore-starter-toolkit

requirements.txtファイルを作成します。

./agent/requirements.txt
strands-agents
bedrock-agentcore

AgentCoreのデプロイ設定をします。

agentcore configure --region us-east-1 --entrypoint main.py

上記のコマンドを実行すると、いろいろと質問されます。

以下、IAMロールについては、空欄のままとします。
空欄の場合、自動でIAMロールが作成されます。

🔐 Execution Role
Press Enter to auto-create execution role, or provide execution role ARN/name to use existing
Previously configured: arn:aws:iam::123456789012:role/AmazonBedrockAgentCoreSDKRuntime-us-east-1-1q1q1q1qq1q1
Execution role ARN/name (or press Enter to auto-create):

以下、ECRリポジトリについては、空欄のままとします。
空欄の場合、自動でECRリポジトリが作成されます。

🏗️  ECR Repository
Press Enter to auto-create ECR repository, or provide ECR Repository URI to use existing
Previously configured: 123456789012.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-main
ECR Repository URI (or press Enter to auto-create):

以下、依存関係を聞かれますが、空欄のままとします。
空欄の場合、デフォルトでrequirements.txtが指定されます。

🔍 Detected dependency file: requirements.txt
Press Enter to use this file, or type a different path (use Tab for autocomplete):
Path or Press Enter to use detected dependency file:

以下、認証設定を聞かれますが、空欄のままとします。
空欄の場合、デフォルトで認証無しに設定されますが、後述の手順で設定します。

🔐 Authorization Configuration
By default, Bedrock AgentCore uses IAM authorization.
Configure OAuth authorizer instead? (yes/no) [no]:

以下のコマンドを実行して、AWS環境のAgentCore Runtimeにデプロイします。
デプロイに成功すると、ログにAgentCoreのARNが出力されるので、メモしておきます。(後ほど使います)

agentcore launch

Windowsで実行すると「PermissionError: [WinError 32] プロセスはファイルにアクセスできません。別のプロセスが使用中です。」が発生する場合があります。その場合、Wslで実行すれば回避できます。

Step3. Vite のセットアップを行う

ViteのプロジェクトをTypeScript + Reactで作成します。

npm create vite@latest
cd <プロジェクト名>
npm install

必要な依存関係をインストールします。

npm install tailwindcss @tailwindcss/vite @aws-amplify/ui-react

tailwindcss と @tailwindcss/viteは、今回CSSのスタイルにTailwindを使っているためなので、任意のライブラリを使用できます。

tailwindcssを設定するため、既存のファイルを修正します。

./frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    react(),
    tailwindcss()
  ],
})

./frontend/index.css
@import 'tailwindcss';

// 省略

Step4. AWS Amplify Gen2のセットアップとAWS Lambdaを設定する

AWS Amplify Gen2のプロジェクトを作成します。

npm create amplify@latest

Lambda用のフォルダを作成し、Node.jsプロジェクトをセットアップします。

cd amplify
mkdir functions
mkdir sseFuction
cd functions/sseFuction
npm init -y

必要な依存関係をインストールします。

npm install --save-dev @types/node
npm install aws-jwt-verify hono

aws-jwt-verifyは、Amazon Cognitoが発行するJWTトークンの認証に使用します。
honoは、Webフレームワークで、サーバー送信イベント(SSE)のやり取りする機能が提供されていたので、使用しています。

Node.jsで動作するLambdaのコード書いていきます。

handler.ts
handler.ts
import { Hono } from 'hono'
import { streamHandle } from 'hono/aws-lambda'
import { streamSSE, SSEStreamingApi } from 'hono/streaming'
import { verifyJWT } from './lib/auth-utils';
import { getErrorMessage, logError } from './lib/error-utils';

// AWS Bedrock AgentCoreのベースURL
const BEDROCK_AGENT_CORE_ENDPOINT_URL = "https://bedrock-agentcore.us-east-1.amazonaws.com"

/**
 * リクエストからIDトークンとアクセストークンを抽出・検証する
 * @param req Lambda eventオブジェクト
 * @returns 検証済みのIDトークンとアクセストークン
 */
async function authenticate(req: any): Promise<{ idToken: string; accessToken: string }> {
  // AuthorizationヘッダーからIDトークンを取得
  const authHeader = req.header('authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('Missing ID token');
  }

  // IDトークンを抽出してJWT検証
  const idToken = authHeader.substring(7);
  const isValid = await verifyJWT(idToken);
  if (!isValid) {
    throw new Error('Invalid ID token');
  }

  // カスタムヘッダーからアクセストークンを取得
  const accessToken = req.header('x-access-token');
  if (!accessToken) {
    throw new Error('Missing access token');
  }

  return { idToken, accessToken };
}

/**
 * AWS Bedrock AgentCoreとの通信を処理し、レスポンスをSSE形式でストリーミングする
 * @param accessToken AWS Cognitoアクセストークン
 * @param prompt ユーザーからの入力プロンプト
 * @param controller ReadableStreamのコントローラー
 */
async function streamFromAgentCore(
  accessToken: string,
  prompt: string,
  stream: SSEStreamingApi
): Promise<void> {

  // 環境変数からエージェントエンドポイントIDを取得
  const agentEndpoint = process.env.AGENT_CORE_ENDPOINT || '';
  const fullUrl = `${BEDROCK_AGENT_CORE_ENDPOINT_URL}/runtimes/${encodeURIComponent(agentEndpoint)}/invocations`;

  // AgentCoreにPOSTリクエストを送信
  console.log("Sending request to AgentCore:", fullUrl);
  const response = await fetch(fullUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ prompt: prompt.trim() }),
  });
  console.log("AgentCore response received1");

  // レスポンスステータスをチェック
  if (!response.ok) {
    console.log("AgentCore returned error:", response.status, response.statusText);
    throw new Error(`AgentCore returned ${response.status}: ${response.statusText}`);
  }

  // レスポンスボディの存在確認
  if (!response.body) {
    console.log("No response body from AgentCore");
    throw new Error('No response body from AgentCore');
  }
  console.log("AgentCore response received2");

  // AgentCoreからレスポンスを受信してから開始メッセージを送信
  await stream.writeSSE({ 
    data: JSON.stringify({ type: 'start', message: 'AgentCore connection established' }) 
  });

  // ストリーミングレスポンスの処理開始
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let chunkCount = 0;

  try {
    while (true) {
      const { done, value } = await reader.read();

      console.log("Read chunk:", { done, valueLength: value?.length });

      if (done) {
        console.log("Reader done, total chunks:", chunkCount);
        break;
      }

      chunkCount++;
      console.log(`Processing chunk ${chunkCount}, size:`, value?.length);

      // バイナリデータをテキストに変換してバッファに追加
      buffer += decoder.decode(value, { stream: true });

      // 改行区切りでデータを処理
      let newlineIndex;
      while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, newlineIndex).trim();
        buffer = buffer.slice(newlineIndex + 1);

        if (!line) continue;

        // SSE形式(Server-Sent Events)の処理
        if (line.startsWith('data: ')) {
          const data = line.slice(6).trim();

          if (data === '[DONE]') {
            console.log("Received [DONE] signal");
            return;
          }

          try {
            const parsed: JSON = JSON.parse(data);
            console.log("Sending SSE data:", JSON.stringify(parsed));
            stream.writeSSE({ data: JSON.stringify(parsed) });
          } catch {
            // JSONパースエラーは無視して続行
          }
        } else {
          // JSON形式の直接レスポンスの処理
          try {
            const parsed = JSON.parse(line);
            console.log("Sending direct JSON data:", JSON.stringify(parsed));
            stream.writeSSE({ data: JSON.stringify(parsed) });
          } catch {
            // JSONパースエラーは無視して続行
            console.warn("JSON parse error for direct data:", line.substring(0, 100));
          }
        }
      }
    }

    // バッファに残った最後のデータを処理
    if (buffer.trim()) {
      try {
        const parsed = JSON.parse(buffer);
        stream.writeSSE({ data: JSON.stringify(parsed) });
      } catch {
        // JSONパースエラーは無視
      }
    }
  } finally {
    // リソースを確実に解放
    reader.releaseLock();
    console.log("AgentCore streaming completed");
  }
}

const app = new Hono()

app.post('/', async (c) => {
  return streamSSE(c, async (stream) => {
    try {
      // ユーザー認証の実行
      const { accessToken } = await authenticate(c.req);

      // リクエストボディからプロンプトを取得
      const { prompt } = await c.req.json();
      if (!prompt?.trim()) {
        stream.writeSSE({ data: 'Bad Request: Empty prompt' });
        stream.close();
        return;
      }

      // SSE(Server-Sent Events)ストリームを作成
      console.log("Creating SSE Stream");
      await streamFromAgentCore(accessToken, prompt, stream);

      console.log("SSE Stream Created");
    } catch (error) {
      console.error("Error in SSE handler:", error);
      const errorMessage = getErrorMessage(error);
      stream.writeSSE({ data: errorMessage });

    } finally {
      console.log("Closing SSE stream");
      stream.close();
    }
  })
})

export const handler = streamHandle(app)
lib/auth-utils.ts
lib/auth-utils.ts
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import { logError } from './error-utils';

const verifier = CognitoJwtVerifier.create({
    tokenUse: 'id',
    userPoolId: process.env.USER_POOL_ID || '',
    clientId: process.env.USER_POOL_CLIENT_ID || '',
});

export async function verifyJWT(token: string): Promise<boolean> {
    try {
        const payload = await verifier.verify(token);
        console.log('JWT検証成功:', payload.sub); // ユーザーID
        return true;
    } catch (error) {
        logError('JWT検証', error);
        return false;
    }
}

lib/error-utils.ts
lib/error-utils.ts
/**
 * unknown型のエラーからメッセージを安全に取得
 */
export function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  
  if (typeof error === 'string') {
    return error;
  }
  
  if (error && typeof error === 'object' && 'message' in error) {
    return String(error.message);
  }
  
  return 'Unknown error occurred';
}

/**
 * エラーログを統一的に出力
 */
export function logError(context: string, error: unknown): void {
  const message = getErrorMessage(error);
  console.error(`[${context}] ${message}`, error);
}

/**
 * APIエラーの詳細情報を取得
 */
export function getApiErrorDetails(error: unknown): {
  message: string;
  status?: number;
  code?: string;
} {
  if (error instanceof Error) {
    // Fetch APIのエラーの場合
    if (error.message.includes('HTTP')) {
      const match = error.message.match(/HTTP (\d+):/);
      const status = match ? parseInt(match[1]) : undefined;
      return {
        message: error.message,
        status,
      };
    }
    
    return {
      message: error.message,
    };
  }
  
  return {
    message: getErrorMessage(error),
  };
}

Amplify FunctionsでLambdaを定義します。
environmentのAGENT_CORE_ENDPOINTには、先ほどAgentCoreをデプロイした際のARNを指定します。

resource.ts
import { defineFunction } from '@aws-amplify/backend';


export const sseFunction = defineFunction({
  name: 'sseFunction',
  entry: './handler.ts',
  timeoutSeconds: 180,
  environment: {
    AGENT_CORE_ENDPOINT: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/main-xxxxxxxx',
  }
});


AmplifyのdefineFunctionでは、関数URLとストリーミングレスポンスを設定出来ないようなので、以下のようにリソースを直接更新します。

amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import * as aws_lambda from 'aws-cdk-lib/aws-lambda';
import { auth } from './auth/resource';
import { sseFunction } from './functions/sseFunction/resource';


const backend = defineBackend({
  auth,
  sseFunction
});

backend.sseFunction.addEnvironment('USER_POOL_ID', backend.auth.resources.userPool.userPoolId);
backend.sseFunction.addEnvironment('USER_POOL_CLIENT_ID', backend.auth.resources.userPoolClient.userPoolClientId);

// 関数URLとストリーミングレスポンスを設定
const sseFunctionUrl = new aws_lambda.CfnUrl(backend.stack, 'SSEFunctionUrl', {
  targetFunctionArn: backend.sseFunction.resources.lambda.functionArn,
  authType: 'NONE',
  invokeMode: 'RESPONSE_STREAM',
  cors: {
    allowOrigins: ['*'],
    allowMethods: ['POST'],
    allowHeaders: ['content-type','authorization','accept','x-access-token'],
    maxAge: 86400
  }
});

backend.addOutput({
  custom: {
    sseFunctionUrl: sseFunctionUrl.attrFunctionUrl
  }
});

Step5. フロントエンドのロジックを実装する

このあたりは前回のNext.jsと一緒です。

useSSEChat.ts
./frontend/src/hooks/useSSEChat.ts
import { useState, useCallback } from 'react';
import { useAuth } from './useAuth';
import { custom } from '../../amplify_outputs.json';

/**
 * SSEチャット機能のオプション設定
 */
interface SSEChatOptions {
  maxRetries?: number;    // 最大再試行回数
  retryDelay?: number;    // 再試行間隔(ミリ秒)
}

/**
 * SSE形式の行からデータ部分を抽出する
 * @param line SSEの1行
 * @returns 抽出されたデータ、または null
 */
const extractDataFromLine = (line: string): string | null => {
  if (line.startsWith('data: ')) {
    return line.slice(6).trim();
  }
  return null;
};

/**
 * パースされたJSONからメッセージ内容を抽出する
 * @param parsed パースされたJSONオブジェクト
 * @returns 抽出されたテキスト内容、または null
 */
const extractMessageContent = (parsed: Record<string, unknown>): string | null => {
  // エラーチェック
  if (parsed.error && typeof parsed.error === 'string') {
    throw new Error(parsed.error);
  }

  if (parsed.event && typeof parsed.event === 'object' && parsed.event !== null) {
    const event = parsed.event as { contentBlockDelta?: { delta?: { text?: string } } };
    if (event.contentBlockDelta?.delta?.text) {
      return event.contentBlockDelta.delta.text;
    }
  }

  return null;
};

/**
 * SSEレスポンスを処理してメッセージを更新する
 * @param response Fetchレスポンス
 * @param onMessageUpdate メッセージ更新時のコールバック
 * @param onComplete 完了時のコールバック
 */
const processStreamingResponse = async (
  response: Response,
  onMessageUpdate: (message: string) => void,
  onComplete: (finalMessage: string) => void
): Promise<void> => {
  console.log("body:", response.body);

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let currentMessage = '';
  let buffer = '';

  console.log("Processing SSE response:", response);

  try {
    while (true) {
      const { done, value } = await reader.read();
      
      // チャンクごとにログ出力
      console.log('[SSE chunk]', value);

      if (done) {
        console.log("SSE response stream completed");
        break;
      }

      // バイナリデータをテキストに変換
      buffer += decoder.decode(value, { stream: true });

      // 改行で分割して各行を処理
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        if (!line.trim()) continue;

        console.log('[SSE raw line]', line);

        const dataToProcess = extractDataFromLine(line);
        if (!dataToProcess) continue;

        try {
          const parsed = JSON.parse(dataToProcess);
          const content = extractMessageContent(parsed);
          if (content) {
            currentMessage += content;
            onMessageUpdate(currentMessage);
          }
        } catch {
          // JSONパースエラーは無視して続行
        }
      }
    }

    onComplete(currentMessage);
  } finally {
    reader.releaseLock();
  }
};

/**
 * SSE(Server-Sent Events)を使用したチャット機能のカスタムフック
 * 
 * @param options 設定オプション
 * @returns チャット機能のstate と関数
 */
export function useSSEChat(options: SSEChatOptions = {}) {
  const { maxRetries = 3, retryDelay = 1000 } = options;

  const [messages, setMessages] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const { getAuthTokens } = useAuth();

  /**
   * メッセージを送信してAIからの応答を受信する
   * @param prompt ユーザーからの入力プロンプト
   * @param retryCount 現在の再試行回数(内部使用)
   */
  const sendMessage = useCallback(async (
    prompt: string,
    retryCount = 0
  ): Promise<void> => {
    if (!prompt?.trim()) return;

    setIsLoading(true);
    setError(null);

    // 認証トークンを取得
    const { idToken, accessToken } = await getAuthTokens();
    if (!idToken || !accessToken) {
      setError('認証トークンが取得できません');
      setIsLoading(false);
      return;
    }

    try {
      // SSE APIにリクエストを送信
      const response = await fetch(custom.sseFunctionUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream',
          'Authorization': `Bearer ${idToken}`,
          'X-Access-Token': accessToken,
        },
        body: JSON.stringify({ prompt }),
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      if (!response.body) {
        throw new Error('レスポンスボディがありません');
      }

      // 新しいメッセージスロットを追加
      setMessages(prev => [...prev, '']);

      // ストリーミングレスポンスを処理
      await processStreamingResponse(
        response,
        // メッセージ更新時
        (currentMessage) => {
          setMessages(prev => [...prev.slice(0, -1), currentMessage]);
        },
        // 完了時
        (finalMessage) => {
          if (finalMessage) {
            setMessages(prev => [...prev.slice(0, -1), finalMessage]);
          } else {
            setMessages(prev => prev.slice(0, -1));
          }
        }
      );

    } catch (fetchError) {
      // 自動再試行(指数バックオフ)
      if (retryCount < maxRetries) {
        setTimeout(() => {
          sendMessage(prompt, retryCount + 1);
        }, retryDelay * Math.pow(2, retryCount));
      } else {
        const errorMessage = fetchError instanceof Error ? fetchError.message : 'Unknown error';
        setError(`通信エラー: ${errorMessage}`);
      }
    } finally {
      setIsLoading(false);
    }
  }, [getAuthTokens, maxRetries, retryDelay]);

  /**
   * メッセージ履歴をクリアする
   */
  const clearMessages = useCallback(() => {
    setMessages([]);
    setError(null);
  }, []);

  return {
    messages,      // メッセージ履歴
    isLoading,     // 送信中フラグ
    error,         // エラーメッセージ
    sendMessage,   // メッセージ送信関数
    clearMessages, // 履歴クリア関数
  };
}
ChatComponent.tsx
./frontend/src/components/ChatComponent.tsx
import React, { useState, useEffect } from 'react';
import { useSSEChat } from '../hooks/useSSEChat';

export default function ChatComponent() {
  const [input, setInput] = useState('');
  const [allMessages, setAllMessages] = useState<Array<{ type: 'user' | 'ai', content: string }>>([]);

  const { messages, isLoading, error, sendMessage, clearMessages } = useSSEChat({
    maxRetries: 3,
    retryDelay: 1000,
  });

  const handleSend = async () => {
    if (!input.trim() || isLoading) return;

    const currentInput = input;
    setInput(''); // 入力をクリア

    // ユーザーメッセージを追加
    setAllMessages(prev => [...prev, { type: 'user', content: currentInput }]);

    // AIレスポンスを取得
    await sendMessage(currentInput);
  };

  // useSSEChatのmessages配列の変化を監視してallMessagesに反映
  useEffect(() => {
    if (messages.length > 0) {
      const latestMessage = messages[messages.length - 1];

      setAllMessages(prev => {
        // 最後のメッセージがAIメッセージかチェック
        const lastMessage = prev[prev.length - 1];

        if (lastMessage && lastMessage.type === 'ai') {
          // 既存のAIメッセージを更新
          return [...prev.slice(0, -1), { type: 'ai' as const, content: latestMessage }];
        } else {
          // 新しいAIメッセージを追加
          return [...prev, { type: 'ai' as const, content: latestMessage }];
        }
      });
    }
  }, [messages]);

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  const handleClearMessages = () => {
    setAllMessages([]);
    clearMessages();
  };

  return (
    <div className="max-w-4xl mx-auto p-6">
      <div className="bg-white rounded-lg shadow-lg">
        {/* ヘッダー */}
        <div className="border-b p-4 flex justify-between items-center">
          <h1 className="text-xl font-semibold text-gray-800">
            AI チャット
          </h1>
          <button
            onClick={handleClearMessages}
            className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded transition-colors"
          >
            履歴クリア
          </button>
        </div>

        {/* メッセージエリア */}
        <div className="h-96 overflow-y-auto p-4 space-y-4">
          {allMessages.length === 0 ? (
            <div className="text-center text-gray-500 mt-8">
              メッセージを入力してチャットを開始してください
            </div>
          ) : (
            allMessages.map((message, index) => (
              <div key={index} className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}>
                <div className={`rounded-lg p-3 max-w-3xl ${message.type === 'user'
                  ? 'bg-blue-500 text-white'
                  : 'bg-blue-50'
                  }`}>
                  <div className={`text-sm mb-1 ${message.type === 'user'
                    ? 'text-blue-100'
                    : 'text-blue-600'
                    }`}>
                    {message.type === 'user' ? 'あなた' : 'AI'}
                  </div>
                  <div className={`whitespace-pre-wrap ${message.type === 'user'
                    ? 'text-white'
                    : 'text-gray-800'
                    }`}>
                    {message.content}
                    {/* ローディング中のカーソル */}
                    {isLoading && message.type === 'ai' && index === allMessages.length - 1 && (
                      <span className="animate-pulse">|</span>
                    )}
                  </div>
                </div>
              </div>
            ))
          )}

          {/* エラー表示 */}
          {error && (
            <div className="bg-red-50 border border-red-200 rounded-lg p-3">
              <div className="text-red-600 text-sm">
                ⚠️ {error}
              </div>
            </div>
          )}
        </div>

        {/* 入力エリア */}
        <div className="border-t p-4">
          <div className="flex gap-3">
            <textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyPress={handleKeyPress}
              className="flex-1 p-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="メッセージを入力... (Shift+Enterで改行)"
              rows={2}
              disabled={isLoading}
            />
            <button
              onClick={handleSend}
              disabled={isLoading || !input.trim()}
              className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
            >
              {isLoading ? (
                <div className="flex items-center gap-2">
                  <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
                  送信中
                </div>
              ) : (
                '送信'
              )}
            </button>
          </div>

          {/* 接続状態表示 */}
          <div className="mt-2 text-xs text-gray-500">
            {isLoading ? (
              <span className="text-blue-600"> 接続中...</span>
            ) : error ? (
              <span className="text-red-600"> 接続エラー</span>
            ) : (
              <span className="text-green-600"> 準備完了</span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Step6. Amplify Hotingにデプロイする

Amplify Hostingへデプロイするため、amplify.ymlを作成します。

amplify.yml
./amplify.yml
version: 1
applications:
  - appRoot: frontend
    backend:
      phases:
        build:
          commands:
            - npm ci --cache .npm --prefer-offline
            - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
    frontend:
      phases:
        preBuild:
          commands:
            - npm ci
        build:
          commands:
            - npm run build
      artifacts:
        baseDirectory: dist
        files:
          - '**/*'
      cache:
        paths:
          - node_modules/**/*
          - dist/**/*

AWSマネジメントコンソール上からAmplifyのページを開き、「アプリケーションをデプロイ」を押します。
image.png

GitHubを選択して、「次へ」を押します。
image.png

該当のソースコードをプッシュしたブランチを指定し、「次へ」を押します。
image.png

そのまま「次へ」を押します。
image.png
image.png

設定内容を確認して、そのまま「次へ」を押します。
image.png
image.png

「デプロイ済み」になれば、デプロイ完了です。
image.png

Step6. AgentCore に認証機能を設定する

Amplify Hostingへのデプロイが完了すると、Amazon Cognitoのユーザープールが作成されます。ユーザープールのID情報を使って、CognitoとAgentCoreを連携し、認証が出来るようにします。

AWSマネジメントコンソール上からAmazon Cognitoを開き、ユーザープールを確認すると、ユーザープールIDが表示されているので、メモしておきます。
image.png

該当のユーザプールを選択し、「アプリケーションクライアント」を開くと、アプリケーションクライアントIDが表示されているので、メモしておきます。

image.png

Amazon CognitoのID情報が分かったら、AgentCoreを再設定します。

agentcore configure --region us-east-1 --entrypoint main.py

このとき、認証設定を聞かれますが、yesとします。他の設定はそのままで良いです。

🔐 Authorization Configuration
By default, Bedrock AgentCore uses IAM authorization.
Configure OAuth authorizer instead? (yes/no) [no]:yes

続けて、先ほどメモしたAmazon CognitoのID情報を入力します。

  • Enter allowed OAuth client IDs
    • https://cognito-idp. リージョン.amazonaws.com/ユーザプールID/.well-known/openid-configuration」を指定する
  • Enter allowed OAuth client ID
    • ユーザプールクライアントID」を指定する
📋 OAuth Configuration
Enter OAuth discovery URL: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxx/.well-known/openid-configuration
Enter allowed OAuth client IDs (comma-separated): 123456789012
Enter allowed OAuth audience (comma-separated):

設定が終わったら、再度デプロイをします。

agentcore launch

あとはAmplify Hostingのドメインをブラウザで開くと、アプリが利用できます。
image.png

ログイン画面が表示されるはずです。
image.png

まとめ

今回は、AgentCoreのフロントエンドとしてViteを使ってみました。
Next.jsと比較すると、バックエンドは別途用意する必要がありますが、Viteの方がシンプルにフロントエンドは構築できるので、選択肢としてはありかなと思いました。

バックエンドに関しては、今回Lambda(URL+ストリーミングレスポンス)で構築しました。
懸念としては、Lambdaなので最大15分のタイムアウト制限や規模が大きくなった時の同時実行数周りが気になるところではありますが、AIエージェントを小さく始めるという意味ではちょうど良いのかなと思います。

今度はこの構成をベースにマルチエージェント化したり、AgentCoreの別機能でリッチにしていきたいと思います。

8
2
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
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?