はじめに
本記事はAngel Dojo カレンダーの21日目の記事です!
目標
本記事を作成するにあたり、下記を目標にしました。
・AngelDojoで身に着けた技術のアウトプットをすること。
身に着けたスキル(初めて触れた技術)
・AWS Amplify
・Amazon Bedrock
・AWS Lambda
・AWS AppSync
・React
・TypeScript
・Python(Lambda)
・最新のAI技術に触れること。
今回利用するAI(Bedrock)のモデル
Stability AI(SDL v0.8):画像生成
Claude 3.5 Sonnet:テキスト生成
アウトプット
技術のアウトプットとして、
「ユーザが入力した情報から、料理の写真とその料理のレシピを出力するアプリ」を1から作ってみます。
構成とイメージ
フロント
①フロントの実装はAmplifyを利用する。
②ユーザは食べたい料理のジャンル等を入力する。
③ボタンをクリックすることで、料理の画像とレシピが表示される。
インフラ
①ユーザが入力したデータをAppSyncを通してLambdaに送る。
②Lambdaから、Bedrockで料理の画像とレシピを生成する。
③画像をS3に保存し、フロントから参照する。
構築
1.LambdaからBedrockを呼び出して生成した画像をS3に格納する
1.生成した画像を保存するためのS3バケットを作成する
-
AWS Management Consoleにログインする。
-
検索バーで「S3」と入力し、S3のサービス画面に移動する。
-
画面右上の「バケットを作成」をクリックする。
-
バケット名を入力し、画面右下の「バケットを作成」をクリックする。(他の項目はデフォルトのままで大丈夫です。)
2.Lambdaにアタッチするロールを作成する。
-
検索バーで「IAM」と入力し、IAMのサービス画面に移動する。
-
左のメニューから「ロール」を選択する。
-
画面右上の「ロールを作成」をクリックする。
-
「信頼されたエンティティタイプ」で「AWSのサービス」を選択し、「ユースケースの選択」で「Lambda」を選択する。
-
画面右下の「次へ」をクリックする。
-
「許可を追加」で検索バーより下記の2つのポリシーを検索し、チェックを入れる。
・AmazonS3FullAccess
・AmazonBedrockFullAccess -
画面右下の「次へ」をクリックする。
-
ロール名を入力し、画面右下の「ロールを作成」をクリックする。
2.Lambda関数を作成する
-
検索バーで「Lambda」と入力し、Lambdaのサービス画面に移動する。
-
画面右上の「関数の作成」をクリックする 。
-
関数名とランタイムとロールを入力・指定する。boto3が好きなのでpythonで書きます。
-
画面右下の「関数の作成」をクリックする。
3.コード
-
関数一覧より、作成した関数を選択する。
-
コードソースに下記のコードを記載する。
# 必要なライブラリの読み込み import json import boto3 import uuid #今回使用するモデルは現在バージニア北部にしかないため、us-east-1リージョンを指定 bedrock_runtime = boto3.client('bedrock-runtime', region_name='us-east-1') s3 = boto3.client("s3", region_name='ap-northeast-1') bucket_name = '先程作成したバケット名を記載' def lambda_handler(event, context): random_uuid = uuid.uuid4().hex input_text = event['input_text'] image_modelId='stability.stable-diffusion-xl-v1' # 画像生成(SDXL 1.0) image_response = bedrock_runtime.invoke_model( body=json.dumps({"text_prompts": [{"text": input_text}]}), contentType ='application/json', accept ='image/png', modelId = image_modelId ) # 画像をS3にアップロード(ファイル名にUUIDを利用) s3_key = f"{random_uuid}.png" s3.upload_fileobj(response['body'], bucket_name, s3_key, ExtraArgs={'ContentType': 'image/png'}) # 署名付きURLを生成 presigned_url = s3.generate_presigned_url('get_object', Params={'Bucket': bucket_name, 'Key': s3_key}, ExpiresIn=3600) # 署名付きURLを返す return { 'statusCode': 200, 'body': json.dumps({'presigned_url': presigned_url}) }
1. 画面中央の「デプロイ」をクリックする。
4.Lambdaのタイムアウトの設定を変更する
デフォルトの3秒だと足りないので、3分に増やしておきます。
5. Lambdaのテスト
試しにAngel Dojoのイメージ画像を生成してもらいます。
-
画面中央の「テスト」を選択し、「イベントJSON」に下記を記載する。
{ "input_text": "image of angel dojo" }
-
右上の「テスト」をクリックする。
-
テストが成功すると生成した画像の署名付きURLが出力される。
かっこいいイラストが出力されました!
2.S3に保存した画像を使って料理の作り方を生成する
AIが出力した画像をS3に保存することができたので、
次はS3に保存した画像から、料理の作り方をテキストで出力するところの実装をします。
1. コード
先程のLambdaを編集します。
-
コードソースに下記のコードを記載する。
# 必要なライブラリの読み込み import json import boto3 import uuid import urllib.request import base64 #今回使用するモデルは現在バージニア北部にしかないため、us-east-1リージョンを指定 bedrock_runtime = boto3.client('bedrock-runtime', region_name='us-east-1') s3 = boto3.client("s3", region_name='ap-northeast-1') bucket_name = 'foodmenu-generator-s3' def lambda_handler(event, context): random_uuid = uuid.uuid4().hex input_text = event.get('input', {}).get('input_text') image_modelId='stability.stable-diffusion-xl-v1' # 画像生成(SDXL 1.0) image_response = bedrock_runtime.invoke_model( body=json.dumps({"text_prompts": [{"text": input_text}]}), contentType ='application/json', accept ='image/png', modelId = image_modelId ) s3_key = f"{random_uuid}.png" # 画像をS3にアップロード s3.upload_fileobj(image_response['body'], bucket_name, s3_key, ExtraArgs={'ContentType': 'image/png'}) # 署名付きURLを生成 presigned_url = s3.generate_presigned_url('get_object', Params={'Bucket': bucket_name, 'Key': s3_key}, ExpiresIn=3600) # 署名付きURLから画像を取得 with urllib.request.urlopen(presigned_url) as image_file: content_image = base64.b64encode(image_file.read()).decode('utf8') recipe_model_id = 'anthropic.claude-3-sonnet-20240229-v1:0' system_prompt = "必ず日本語で答えてください" max_tokens = 1000 message = "画像の料理のレシピを教えてください。" user_message = { "role": "user", "content": [ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": content_image}}, {"type": "text", "text": message} ] } messages = [user_message] recipe_response = bedrock_runtime.invoke_model( body=json.dumps({ 'anthropic_version': 'bedrock-2023-05-31', 'max_tokens': max_tokens, "system": system_prompt, "messages": messages }), modelId= recipe_model_id ) response_body = json.loads(recipe_response.get('body').read()) recipe_content = response_body.get('content') recipe_text = recipe_content[0].get('text') return { 'statusCode': 200, 'presigned_url': presigned_url, 'recipe_text': recipe_text }
2. Lambdaのテスト
画像からレシピを生成できるかのテストをします。
-
画面中央の「テスト」を選択し、「イベントJSON」に下記を記載する。
{ "input": { "input_text": "a tomato spaghetti" } }
-
右上の「テスト」をクリックする。
-
テストが成功すると生成した画像の署名付きURLと、画像の料理のレシピが出力される。
この画像は伝統的なイタリアン料理、スパゲッティ・アラ・ボロネーゼを示しています。ボロネーゼソースは牛挽き肉、トマト、白ワイン、にんにく、セロリ、玉ねぎなどを使ったラグューソースです。\n\nレシピは以下の通りです。\n\n【材料】\n- スパゲッティ 400g\n- 牛挽き肉 300g\n- トマト缶(ホール) 1缶\n- 白ワイン 1/2カップ\n- にんにく 2かけらみじん切り\n- 玉ねぎ 1個みじん切り \n- セロリの茎 2本みじん切り\n- オリーブオイル\n- バジル\n- パルメザンチーズ\n\n【作り方】\n1. オリーブオイルを熱し、にんにく、玉ねぎ、セロリを炒める\n2. 牛挽き肉を加えて炒め、白ワインを入れる\n3. トマト缶の中身を加え、塩コショウで味を調える\n4. 2-3時間弱火で煮込む\n5. 別鍋でスパゲッティを茹でる\n6. スパゲッティをボロネーゼソースと絡め、バジルやチーズをかける\n\n香り高いラグーソースとパスタが絶妙にマッチした、家庭的でありながらも本格的な一品です。
いい感じに出力されてますね!
システムプロンプトの詳細化など色々とカスタマイズはできますが、
今回はこのまま次に進みます。
3.AppSyncからLambdaを呼び出せるようにする
続いて、フロントからこのLambdaを呼び出せるようにAppSyncでAPIを作成します。
1.APIを作成
-
検索バーで「AppSync」と入力し、AppSyncのサービス画面に移動する。
-
画面右上の「APIを作成」をクリックする。
-
APIタイプを選択し、右下の「次へ」をクリックする。
-
API名を入力し、右下の「次へ」をクリックする。
-
GraphQLリソースを選択し、右下の「次へ」をクリックする。
-
確認画面より、右下の「APIを作成」をクリックする。
2.スキーマの設定
-
画面左のメニューより「スキーマ」を選択し、スキーマに下記を入力する。
input RecipeInput { input_text: String! } type RecipeResponse { presigned_url: String recipe_text: String } type Query { generateRecipe(input: RecipeInput!): RecipeResponse }
-
右上の「スキーマを保存」クリックする。
-
画面右の「リゾルバー」より、「generateRecipe(...): RecipeResponse」のリゾルバーの「アタッチ」をクリックする。
-
「データソース」は作成する必要があるため、「新しいデータソースを作成」をクリックする。
-
下記を参考にパラメータを入力し、右下の「作成」をクリックする。
-
作成が完了すると作成したデータソースが選択できるようになるので、選択して右下の「作成」をクリックする。
(表示されない場合は選択画面右の更新ボタンをクリックすると表示される) -
マッピングテンプレートはレスポンス側のみ変更し、右上の「保存」をクリックする。
{ "presigned_url": $util.toJson($context.result.presigned_url), "recipe_text": $util.toJson($context.result.recipe_text) }
3.クエリのテスト
上手く取得できてそうです!
4.フロント画面を作成する
インフラ側の構築が一段落したので、ここからはフロント画面の作成をします。
1. 事前準備
- GitHubにリポジトリを作成する
- ローカル環境でReactとViteのプロジェクトを作成する
- ローカルからGitHubにプッシュする
このあたりの環境構築はこの方の記事が分かりやすいです。
2. Amplifyの作成
-
検索バーで「Amplify」と入力し、Amplifyのサービス画面に移動する。
-
「新しいアプリを作成」をクリックする。
-
GitHubからデプロイをするように選択し、右下の「次へ」をクリックする。
-
リポジトリとブランチを追加し、右下の「次へ」をクリックする。
-
アプリケーション名を入力して、右下の「次へ」をクリックする。
-
入力内容を確認して右下の「保存してデプロイ」をクリックする。
-
デプロイが成功すると下記のような画面が表示される。
-
ドメインのURLをクリックするとこの画面が表示される。
3.フロント画面の作成
ほぼAIに手伝ってもらったので省略します・・
最終的にこんな感じになりました。
内容としては、ユーザーが食べたいものをテキストで入力し、「今日の晩御飯を決める」をクリックすることで、AppSyncを介してLambdaにそのテキストが送信されます。画像の署名付きURLとレシピのテキストが返ってくるので、フロントで画像とテキストを表示するようになっています。
参考程度ですがコードを載せておきます。
App.tsx
import { useState } from 'react';
import './App.css';
import { Amplify } from 'aws-amplify';
import config from './aws-exports.js';
// AWS Amplifyの設定
Amplify.configure(config);
// AppSyncからのレスポンスの型定義
type AppSyncResponse = {
presigned_url: string;
recipe_text: string;
}
export default function App() {
// ステートの定義
const [isLoading, setIsLoading] = useState(false); // ローディング状態
const [prompt, setPrompt] = useState(""); // ユーザーが入力したテキスト
const [dinner, setDinner] = useState<AppSyncResponse | null>(null); // 生成された晩御飯の情報
const [error, setError] = useState<string | null>(null); // エラーメッセージ
// レシピ生成の関数
const generateRecipe = async () => {
setIsLoading(true); // ローディング開始
setError(null); // エラーメッセージをリセット
try {
// GraphQL APIへのリクエスト
const response = await fetch(config.API.GraphQL.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json', // リクエストのヘッダー
'x-api-key': config.API.GraphQL.apiKey, // APIキー
},
body: JSON.stringify({
query: `
query MyQuery {
generateRecipe(input: {input_text: "${prompt}"}) {
presigned_url
recipe_text
}
}
`,
}),
});
// レスポンスの処理
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
// 生成された晩御飯の情報を更新
setDinner(result.data.generateRecipe);
} catch (err) {
// エラー処理
setError(err instanceof Error ? err.message : 'エラーが発生しました。');
} finally {
// ローディング終了
setIsLoading(false);
}
};
return (
<div className="app-container">
<div className="dinner-generator">
<div className="dinner-header">
<h2 className="dinner-title">
<span className="dinner-icon">🍽️</span>
晩御飯ジェネレータ
</h2>
</div>
<div className="dinner-content">
<div className="prompt-container">
<label htmlFor="prompt" className="prompt-label">
今日の気分や食べたいものを入力してください:
</label>
<textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)} // テキストエリアの変更を監視
placeholder="spaghetti"
className="prompt-input"
/>
</div>
{error && (
<div className="error-message">
<h3>エラー</h3>
<p>{error}</p>
</div>
)}
{dinner && (
<>
<div className="dinner-image-container">
<img
alt="生成された晩御飯の画像"
src={dinner.presigned_url}
className="dinner-image"
/>
</div>
<div className="recipe-container">
<h3>作り方:</h3>
<p>{dinner.recipe_text}</p>
</div>
</>
)}
</div>
<div className="dinner-footer">
<button
onClick={generateRecipe} // ボタンがクリックされた時にgenerateRecipeを実行
disabled={isLoading || prompt.trim() === ''} // ローディング中または入力が空のときは無効化
className={`generate-button ${isLoading ? 'loading' : ''}`} // ローディング中のスタイル
>
{isLoading ? '決定中...' : '今日の晩御飯を決める'}
</button>
</div>
</div>
</div>
);
}
App.css
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400;700&display=swap');
.app-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-image: url('/japanese-pattern.svg');
font-family: 'Noto Serif JP', serif;
}
.dinner-generator {
width: 100%;
max-width: 42rem;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.dinner-header {
border-bottom: 1px solid #e5e7eb;
padding: 1rem;
}
.dinner-title {
text-align: center;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bold;
}
.dinner-icon {
margin-right: 0.5rem;
}
.dinner-content {
padding: 1rem;
}
.prompt-container {
margin-bottom: 1rem;
}
.prompt-label {
display: block;
margin-bottom: 0.5rem;
}
.prompt-input {
width: 100%;
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid #d1d5db;
resize: vertical;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.error-message h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.error-message p {
margin: 0;
}
.dinner-image-container {
position: relative;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.dinner-image {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.recipe-container {
max-height: 200px;
overflow-y: auto;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
background: #f9fafb;
}
.recipe-container h3 {
font-weight: bold;
margin-bottom: 0.5rem;
}
.recipe-container p {
white-space: pre-line;
margin: 0;
}
.dinner-footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
.generate-button {
width: 100%;
padding: 0.75rem;
background: #f59e0b;
color: white;
border: none;
border-radius: 9999px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.generate-button:hover:not(:disabled) {
background: #d97706;
}
.generate-button:disabled {
background: #d97706;
cursor: not-allowed;
}
.generate-button.loading {
background: #d97706;
}
実際に使ってみる
テキストボックスに食べたい料理の名前を入力し、オレンジのボタンをクリックします。
ボタンをクリックすると、「決定中・・」と表示されます。
約20秒ほどで下記のような画面が出力されます!
よく見るとグロい!
さいごに
完成度としてはまだまだですが、インフラからフロントまでを一人で作ることができたのはAngel Dojoに参加したおかげです。ReactやTypeScriptは正直よく分かってないので今後も勉強していきたいです!
改善の余地が沢山あるので、機会があればまた記事にしたいと思います。
最後まで読んでいただきありがとうございました。
参考