少し前に、Amplify Gen2にAI Kitが追加されました。
発表直後にいろいろな人がやってみたブログを公開されていて、気になってたのですがなかなか手を付けられずにいました。
サンプルプロジェクトも公開されていて、簡単に試せそうだったのですが、せっかくなので理解する意味も込めて、スクラッチでチャットアプリを構築しました!
完成した画面がこちら!!
誰かの役に立てばと思い、手順を紹介させていただきます。あまり凝ったことはしてないですが、その分コードもシンプルなのでお役に立てれば嬉しいです。
(見た目も自分が作ったものにしてはきれいな気がする)
準備
ViteでReactアプリを新規作成し、Tailwind CSSを導入します。
参考:https://tailwindcss.com/docs/guides/vite
ViteでReactアプリを作成します
npm create vite@latest chat-app -- --template react
以下のファイルが生成されます。
chat-app/
├── eslint.config.js
├── index.html
├── package.json
├── public
│ └── vite.svg
├── README.md
├── src
│ ├── App.css
│ ├── App.jsx
│ ├── assets
│ │ └── react.svg
│ ├── index.css
│ └── main.jsx
└── vite.config.js
3 directories, 11 files
Tailwind CSSを導入します
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.jsとpostcss.config.jsが新規作成されます。
tailwind.config.jsを編集します。
/** @type {import('tailwindcss').Config} */
export default {
+ content: [
+ './index.html',
+ './src/**/*.{js,ts,jsx,tsx}'
+ ],
theme: {
extend: {},
},
plugins: [],
}
src/index.cssの内容を一旦全部削除し、以下の内容で上書きします。
@tailwind base;
@tailwind components;
@tailwind utilities;
src/App.jsxも一旦削除し、以下の内容で上書きします。
function App() {
return (
<>
<h1 className="text-3xl font-bold underline">
Hello world!
</h1>
</>
)
}
export default App
起動して確認します。
npm run dev
下線が引かれて太字になった「Hello world!」が表示されることを確認してください。
devcontainer環境でプレビューが表示されない場合は、package.json
を一部修正します。
- "dev": "vite",
+ "dev": "vite --host",
チャット画面の雛形を生成する
ここでいきなりですが、Claude.aiにチャットUIを考えてもらいました。
わかりやすいように、1秒後にオウム返しするよう指定しました。
App.jsx
import { useState, useRef, useEffect } from 'react';
const ChatArea = () => {
const [messages, setMessages] = useState([
{ id: 1, content: "こんにちは!", role: "user" },
{ id: 2, content: "お手伝いできることはありますか?", role: "assistant" }
]);
const [newMessage, setNewMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
// ユーザーメッセージを追加
const userMessage = {
id: messages.length + 1,
content: newMessage,
role: "user"
};
setMessages(prev => [...prev, userMessage]);
setNewMessage("");
setIsLoading(true);
// 1秒後にアシスタントの返信を追加
setTimeout(() => {
const assistantMessage = {
id: messages.length + 2,
content: userMessage.content,
role: "assistant"
};
setMessages(prev => [...prev, assistantMessage]);
setIsLoading(false);
}, 1000);
};
return (
<div className="fixed inset-0 flex flex-col bg-gray-50">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-2xl mx-auto space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex items-start gap-3 ${message.role === "user" ? "flex-row-reverse" : ""
}`}
>
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white
${message.role === "user" ? "bg-blue-500" : "bg-gray-500"}`}>
{message.role=== "user" ? "U" : "A"}
</div>
{/* Message Bubble */}
<div
className={`rounded-lg p-3 max-w-[80%] ${message.role === "user"
? "bg-blue-500 text-white"
: "bg-white shadow-sm text-gray-800"
}`}
>
{message.content}
</div>
</div>
))}
{/* Typing Indicator */}
{isLoading && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white bg-gray-500">
A
</div>
<div className="rounded-lg p-3 bg-white shadow-sm text-gray-800">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Area */}
<div className="border-t bg-white p-4">
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="メッセージを入力..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
送信
</button>
</form>
</div>
</div>
</div>
);
};
export default ChatArea;
Amplify Gen2を導入する
ReactアプリにAmplify Gen2を導入します。
npm create amplify@latest
Where should we create your project?
と聞かれるので、エンターを入力します。
amplifyディレクトリーが作成され、ファイルが生成されます。
amplify/
├── auth
│ └── resource.ts
├── backend.ts
├── data
│ └── resource.ts
├── package.json
└── tsconfig.json
2 directories, 5 files
UIライブラリーを追加します。
npm add @aws-amplify/ui-react @aws-amplify/ui-react-ai
src/main.jsxに以下の内容を追加します。
import '@aws-amplify/ui-react/styles.css'
import { Amplify } from 'aws-amplify'
import outputs from '../amplify_outputs.json'
ログイン機能の追加とサンドボックス環境の立ち上げ
ログイン機能を追加するにはバックエンドとフロントエンドにそれぞれ機能の追加が必要ですが、Amplifyの導入時にバックエンドは自動的にamplify/auth/resource.ts
が作成されており、設定済みです。
フロントエンド側の処理を追加します。
import { Authenticator } from "@aws-amplify/ui-react";
return (
<Authenticator>
{もともとあったJSXタグ}
</Authenticator>
);
Amplify Gen2の導入ができたので、サンドボックス環境を立ち上げます。
npx ampx sandbox
サンドボックス環境が立ち上がると自動でCognitoが構築されます。
画面を更新すると、Cognitoのログイン画面が表示されます。
アカウントを作成し、ログインすると先程のチャット画面が表示されます。
Amplify AI Kitを追加
それではいよいよAI Kitを追加していきます。
AI KitはAppSyncとLambdaの組み合わせで構成されているようですが、Amplifyが上手に隠蔽してくれているので意識する必要はありません。
ドキュメントに記載はあるのですが、一部説明が省略されているところがあり、詰まったので、ソース全体を記載していきます。
まずはDataリソースです。
使用する生成AIモデルやシステムプロンプトの値を設定します。
以下の例では基盤モデルとして「Amazon Nova Lite」を使用し、システムプロンプトとして「You are a helpful assistant」が指定しています。
import { a, defineData, type ClientSchema } from '@aws-amplify/backend';
const schema = a.schema({
// This will add a new conversation route to your Amplify Data backend.
chat: a.conversation({
aiModel: a.ai.model('Amazon Nova Lite'),
systemPrompt: 'You are a helpful assistant'
})
.authorization((allow) => allow.owner())
});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: "userPool",
},
});
続いて、フロントエンド側のコードとして、バックエンドとのつなぎのコードを作成します。
import { generateClient } from 'aws-amplify/api';
import { Schema } from '../amplify/data/resource';
import { createAIHooks } from '@aws-amplify/ui-react-ai';
export const client = generateClient<Schema>({ authMode: "userPool" });
export const { useAIConversation } = createAIHooks(client);
export type Conversation = Schema["chat"]["type"];
src/App.jsxは色々変更点があるので、順を追って解説します。
まず、先程のclient.tsからuseAIConversation
をインポートします。
import { useAIConversation } from './client';
このuseAIConversation
ですが、チャットメッセージの送信や履歴の管理をいい感じにしてくれるHookです。
- const [messages, setMessages] = useState([
- { id: 1, content: "こんにちは!", sender: "user" },
- { id: 2, content: "お手伝いできることはありますか?", sender: "assistant" }
- ]);
+ const [
+ {
+ data: { messages },
+ isLoading,
+ },
+ handleSendMessage,
+ ] = useAIConversation('chat');
チャット欄入力後に、AWS側にメッセージを送信し、返答を受け取る処理は、handleSendMessage
で行います。
const handleSubmit = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
// メッセージを送信
handleSendMessage({
content: [{ text: newMessage }]
})
setNewMessage("");
};
受け取ったメッセージを出力する部分は、以下のようになります。
- {message.content}
+ {message.content[0].text}
これで画面の修正は完了です。
以下のようにBedrockとチャットができるようになります。
チャットでの会話履歴は自動的に記録してくれている(実際にはDynamoDBに保管されているらしい)ので、複数回のチャットのやり取りが可能です。
この時点のsrc/App.jsx
import { useState, useRef, useEffect } from 'react';
import { Authenticator } from "@aws-amplify/ui-react";
import { useAIConversation } from './client';
const ChatArea = () => {
const [
{
data: { messages },
isLoading,
},
handleSendMessage,
] = useAIConversation('chat');
const [newMessage, setNewMessage] = useState("");
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
// メッセージを送信
handleSendMessage({
content: [{ text: newMessage }]
})
setNewMessage("");
};
return (
<Authenticator>
<div className="fixed inset-0 flex flex-col bg-gray-50">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-2xl mx-auto space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex items-start gap-3 ${message.role === "user" ? "flex-row-reverse" : ""
}`}
>
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white
${message.role === "user" ? "bg-blue-500" : "bg-gray-500"}`}>
{message.role === "user" ? "U" : "A"}
</div>
{/* Message Bubble */}
<div
className={`rounded-lg p-3 max-w-[80%] ${message.role === "user"
? "bg-blue-500 text-white"
: "bg-white shadow-sm text-gray-800"
}`}
>
{message.content[0].text}
</div>
</div>
))}
{/* Typing Indicator */}
{isLoading && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white bg-gray-500">
A
</div>
<div className="rounded-lg p-3 bg-white shadow-sm text-gray-800">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Area */}
<div className="border-t bg-white p-4">
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="メッセージを入力..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
送信
</button>
</form>
</div>
</div>
</div>
</Authenticator>
);
};
export default ChatArea;
スレッド履歴を管理する機能を追加
このままではブラウザをリロードすると、毎回新規で開始されるので、スレッドの一覧を表示し、過去の会話を確認できるようにします。
再度、Claude.aiにお願いして、サイドバーとヘッダーを追加してもらいました。
src/App.jsx
import { useState, useRef, useEffect } from 'react';
import { Authenticator } from "@aws-amplify/ui-react";
import { useAIConversation } from './client';
const ChatInterface = () => {
const [activeConversationId, setActiveConversationId] = useState("");
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [conversations, setConversations] = useState([])
const [chatArea, setChatArea] = useState(<ChatArea></ChatArea>)
const createNewConversation = () => {
/** あとで実装 */
}
const deleteConversation = () => {
/** あとで実装 */
}
return (
<Authenticator>
<div className="fixed inset-0 flex bg-gray-50">
{/* Sidebar */}
<div className={`w-96 bg-white border-r border-gray-200 flex flex-col ${isSidebarOpen ? '' : 'hidden'}`}>
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-200">
<button
onClick={createNewConversation}
className="w-full py-2 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
+ 新規スレッド
</button>
</div>
{/* Thread List */}
<div className="flex-1 overflow-y-auto">
{conversations.map(conversation => (
<div key={conversation.id}
className={`w-full p-4 text-left border-b border-gray-100 hover:bg-gray-50 ${conversation.id === activeConversationId ? 'bg-blue-50' : ''
}`}
>
<button
onClick={() => setActiveConversationId(conversation.id)}
className={`w-full text-left
}`}
>
<div className="font-medium text-gray-900 truncate">{conversation.id}</div>
<div className="text-sm text-gray-500 truncate">{conversation.updatedAt}</div>
{/* <div className="text-xs text-gray-400 mt-1">{"thread.timestamp"}</div> */}
</button>
<button
className="py-1 px-2 bg-red-500 text-white rounded-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
onClick={() => deleteConversation(conversation.id)}
>Delete</button>
</div>
))}
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="bg-white shadow-sm p-4 flex items-center">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="mr-4 p-2 hover:bg-gray-100 rounded-lg"
>
☰
</button>
<h1 className="text-xl font-semibold text-gray-800">チャット</h1>
</div>
{chatArea}
</div>
</div>
</Authenticator>
)
}
const ChatArea = () => {
const [
{
data: { messages },
isLoading,
},
handleSendMessage,
] = useAIConversation('chat');
const [newMessage, setNewMessage] = useState("");
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
// メッセージを送信
handleSendMessage({
content: [{ text: newMessage }]
})
setNewMessage("");
};
return (
<>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-2xl mx-auto space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex items-start gap-3 ${message.role === "user" ? "flex-row-reverse" : ""
}`}
>
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white
${message.role === "user" ? "bg-blue-500" : "bg-gray-500"}`}>
{message.role === "user" ? "U" : "A"}
</div>
{/* Message Bubble */}
<div
className={`rounded-lg p-3 max-w-[80%] ${message.role === "user"
? "bg-blue-500 text-white"
: "bg-white shadow-sm text-gray-800"
}`}
>
{message.content[0].text}
</div>
</div>
))}
{/* Typing Indicator */}
{isLoading && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white bg-gray-500">
A
</div>
<div className="rounded-lg p-3 bg-white shadow-sm text-gray-800">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Area */}
<div className="border-t bg-white p-4">
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="メッセージを入力..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
送信
</button>
</form>
</div>
</div>
</>
);
};
export default ChatInterface;
サイドバーに過去のスレッド一覧を表示させます。
スレッド一覧を取得するには、client.conversations.chat.list
を使用します。また、スレッド一覧を取得し、最初の1件目のIDをactiveConversationIdとして状態管理します。
- import { useAIConversation } from './client';
+ import { useAIConversation, client } from './client';
+ useEffect(() => {
+ client.conversations.chat.list({ limit: 10 }).then(
+ ({ data }) => {
+ setConversations(data)
+ if (data.length >= 0) {
+ setActiveConversationId(data[0].id)
+ }
+ }
+ )
+ }, [])
activeConversationIdが変更されたら、ChatAreaを更新します。
useEffect(() => {
if (activeConversationId != "") {
setChatArea(<ChatArea key={activeConversationId} id={activeConversationId}></ChatArea>)
}
}, [activeConversationId])
ChatAreaでは、受け取ったactiveConversationId
を使用してuseAIConversationを行うことで、履歴が復元されます。
- const ChatArea = () => {
+ const ChatArea = ({ id }) => {
const [
{
data: { messages },
isLoading,
},
handleSendMessage,
- ] = useAIConversation('chat');
+ ] = useAIConversation('chat', { id: id });
最後に、新規スレッドを開始する処理と、不要なスレッドを削除する処理を追加します。
新規スレッドを開始:client.conversations.chat.create
でスレッドを作成し、IDを渡す
不要スレッドの削除:client.conversations.chat.delete
でスレッドを削除
const createNewConversation = () => {
client.conversations.chat.create()
.then(({ data }) => {
setConversations([data, ...conversations])
setActiveConversationId(data.id)
}
)
}
const deleteConversation = (deleteConversationId) => {
if (deleteConversationId === activeConversationId) {
setActiveConversationId(conversations[0].id)
}
setConversations(
conversations.filter((conversation => conversation.id !== deleteConversationId))
)
client.conversations.chat.delete({ id: deleteConversationId })
}
ソースコード置いときます