はじめに
先日、Next.jsとAmazon Bedrock Agent Coreを使って、簡単なAIチャットアプリを作ってみました。
ただ、前回の構成には以下のような課題(※個人の感想)があったので、別の構成を考えてみました。
- デプロイ環境がAWSとVecelに分かれてしまい2重管理
- Next.jsは機能豊富だけど、シンプルな構成にはオーバースペック
ということで今回は、以下のような構成で作り直してみました。
- フロントエンド:Vite
- バックエンド:AWS Lambda(URL経由でストリーミングレスポンス有効)
コードはこちら
アーキテクチャ
アーキテクチャは以下になります。
ポイント
-
すべてのコンポーネントをAWS上にデプロイ可能になった
Agent Core以外の部分は、AWS Amplifyを使って完結できるようになりました。 -
バックエンドは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は任意のモデルを使用してください。
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環境のリソース作成や設定を行ってくれる便利なツールです
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
を設定するため、既存のファイルを修正します。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss()
],
})
@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-verif
yは、Amazon Cognitoが発行するJWTトークンの認証に使用します。
hono
は、Webフレームワークで、サーバー送信イベント(SSE)のやり取りする機能が提供されていたので、使用しています。
Node.jsで動作するLambdaのコード書いていきます。
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
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
/**
* 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を指定します。
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とストリーミングレスポンスを設定出来ないようなので、以下のようにリソースを直接更新します。
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
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
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
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のページを開き、「アプリケーションをデプロイ」を押します。
該当のソースコードをプッシュしたブランチを指定し、「次へ」を押します。
Step6. AgentCore に認証機能を設定する
Amplify Hostingへのデプロイが完了すると、Amazon Cognitoのユーザープールが作成されます。ユーザープールのID情報を使って、CognitoとAgentCoreを連携し、認証が出来るようにします。
AWSマネジメントコンソール上からAmazon Cognitoを開き、ユーザープールを確認すると、ユーザープールID
が表示されているので、メモしておきます。
該当のユーザプールを選択し、「アプリケーションクライアント」を開くと、アプリケーションクライアントID
が表示されているので、メモしておきます。
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」を指定する
- 「https://cognito-idp.
-
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のドメインをブラウザで開くと、アプリが利用できます。
まとめ
今回は、AgentCoreのフロントエンドとしてViteを使ってみました。
Next.jsと比較すると、バックエンドは別途用意する必要がありますが、Viteの方がシンプルにフロントエンドは構築できるので、選択肢としてはありかなと思いました。
バックエンドに関しては、今回Lambda(URL+ストリーミングレスポンス)で構築しました。
懸念としては、Lambdaなので最大15分のタイムアウト制限や規模が大きくなった時の同時実行数周りが気になるところではありますが、AIエージェントを小さく始めるという意味ではちょうど良いのかなと思います。
今度はこの構成をベースにマルチエージェント化したり、AgentCoreの別機能でリッチにしていきたいと思います。