10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amplify Gen2とBedrockでチャットアプリをゼロから作成する方法を紹介します

Posted at

少し前に、Amplify Gen2にAI Kitが追加されました。

発表直後にいろいろな人がやってみたブログを公開されていて、気になってたのですがなかなか手を付けられずにいました。

サンプルプロジェクトも公開されていて、簡単に試せそうだったのですが、せっかくなので理解する意味も込めて、スクラッチでチャットアプリを構築しました!

完成した画面がこちら!!

image.png

誰かの役に立てばと思い、手順を紹介させていただきます。あまり凝ったことはしてないですが、その分コードもシンプルなのでお役に立てれば嬉しいです。
(見た目も自分が作ったものにしてはきれいな気がする)

準備

ViteでReactアプリを新規作成し、Tailwind CSSを導入します。

参考:https://tailwindcss.com/docs/guides/vite

ViteでReactアプリを作成します

npm create vite@latest chat-app -- --template react

以下のファイルが生成されます。

tree
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を編集します。

tailwind.config.js
  /** @type {import('tailwindcss').Config} */
  export default {
+   content: [
+     './index.html',
+     './src/**/*.{js,ts,jsx,tsx}'
+   ],
    theme: {
      extend: {},
    },
    plugins: [],
  }

src/index.cssの内容を一旦全部削除し、以下の内容で上書きします。

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

src/App.jsxも一旦削除し、以下の内容で上書きします。

src/App.jsx
function App() {
  return (
    <>
      <h1 className="text-3xl font-bold underline">
        Hello world!
      </h1>
    </>
  )
}

export default App

起動して確認します。

npm run dev

image.png

下線が引かれて太字になった「Hello world!」が表示されることを確認してください。

devcontainer環境でプレビューが表示されない場合は、package.jsonを一部修正します。

package.json
- "dev": "vite",
+ "dev": "vite --host",

チャット画面の雛形を生成する

ここでいきなりですが、Claude.aiにチャットUIを考えてもらいました。
わかりやすいように、1秒後にオウム返しするよう指定しました。

App.jsx
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ディレクトリーが作成され、ファイルが生成されます。

tree 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に以下の内容を追加します。

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が作成されており、設定済みです。

フロントエンド側の処理を追加します。

chat-app/src/App.jsx
import { Authenticator } from "@aws-amplify/ui-react";
chat-app/src/App.jsx
  return (
    <Authenticator>

    {もともとあったJSXタグ}
    
    </Authenticator>
  );

Amplify Gen2の導入ができたので、サンドボックス環境を立ち上げます。

npx ampx sandbox

サンドボックス環境が立ち上がると自動でCognitoが構築されます。
画面を更新すると、Cognitoのログイン画面が表示されます。

image.png

アカウントを作成し、ログインすると先程のチャット画面が表示されます。

image.png

Amplify AI Kitを追加

それではいよいよAI Kitを追加していきます。

AI KitはAppSyncとLambdaの組み合わせで構成されているようですが、Amplifyが上手に隠蔽してくれているので意識する必要はありません。

ドキュメントに記載はあるのですが、一部説明が省略されているところがあり、詰まったので、ソース全体を記載していきます。

まずはDataリソースです。

使用する生成AIモデルやシステムプロンプトの値を設定します。

以下の例では基盤モデルとして「Amazon Nova Lite」を使用し、システムプロンプトとして「You are a helpful assistant」が指定しています。

amplify/data/resource.ts(一旦全消しして上書き)
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",
    },
});

続いて、フロントエンド側のコードとして、バックエンドとのつなぎのコードを作成します。

src/client.ts(新規作成)
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をインポートします。

src/App.jsx(追加)
import { useAIConversation } from './client';

このuseAIConversationですが、チャットメッセージの送信や履歴の管理をいい感じにしてくれるHookです。

src/src/App.jsx(変更)
-  const [messages, setMessages] = useState([
-    { id: 1, content: "こんにちは!", sender: "user" },
-    { id: 2, content: "お手伝いできることはありますか?", sender: "assistant" }
-  ]); 
+  const [
+    {
+      data: { messages },
+      isLoading,
+    },
+    handleSendMessage,
+  ] = useAIConversation('chat');

チャット欄入力後に、AWS側にメッセージを送信し、返答を受け取る処理は、handleSendMessageで行います。

src/src/App.jsx(関数を置き換え)
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!newMessage.trim()) return;

    // メッセージを送信
    handleSendMessage({
      content: [{ text: newMessage }]
    })

    setNewMessage("");
  };

受け取ったメッセージを出力する部分は、以下のようになります。

src/App.jsx(変更)
- {message.content}
+ {message.content[0].text}

これで画面の修正は完了です。

以下のようにBedrockとチャットができるようになります。

チャットでの会話履歴は自動的に記録してくれている(実際にはDynamoDBに保管されているらしい)ので、複数回のチャットのやり取りが可能です。

この時点のsrc/App.jsx
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
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;

image.png

サイドバーに過去のスレッド一覧を表示させます。
スレッド一覧を取得するには、client.conversations.chat.listを使用します。また、スレッド一覧を取得し、最初の1件目のIDをactiveConversationIdとして状態管理します。

src/App.jsx(差分)
- 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を更新します。

src/App.jsx(追加)
  useEffect(() => {
    if (activeConversationId != "") {
      setChatArea(<ChatArea key={activeConversationId} id={activeConversationId}></ChatArea>)
    }
  }, [activeConversationId])

ChatAreaでは、受け取ったactiveConversationIdを使用してuseAIConversationを行うことで、履歴が復元されます。

src/App.jsx(差分)
- const ChatArea = () => {
+ const ChatArea = ({ id }) => {

    const [
      {
        data: { messages },
        isLoading,
      },
      handleSendMessage,
-  ] = useAIConversation('chat');
+  ] = useAIConversation('chat', { id: id });

image.png

最後に、新規スレッドを開始する処理と、不要なスレッドを削除する処理を追加します。

新規スレッドを開始:client.conversations.chat.createでスレッドを作成し、IDを渡す
不要スレッドの削除:client.conversations.chat.deleteでスレッドを削除

src/App.jsx(関数内の実装を追加)
  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 })
  }

ソースコード置いときます

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?