Front-End Web & Mobile

Creating a Generative AI Travel Assistant App with Amazon Bedrock and AWS Amplify

In this post, we will walk you through creating a travel assistant app. The app will provide a personalized experience by suggesting popular attractions, local experiences, and hidden gems for the user’s desired destination. We will build this app using AWS Amplify and Amazon Bedrock.

AWS Amplify Gen 2 uses a TypeScript-based, code-first developer experience (DX) for defining backends. The Gen 2 DX offers a unified Amplify developer experience with hosting, backend, and UI-building capabilities and a code-first approach. Amplify empowers frontend developers to deploy cloud infrastructure by simply expressing their app’s data model, business logic, authentication, and authorization rules completely in TypeScript. Amplify automatically configures the correct cloud resources and removes the requirement to stitch together underlying AWS services.

Amazon Bedrock is a fully managed service offering high-performing foundation models (FMs) from leading AI companies. These FMs are accessible through a single Application Programming Interface (API), along with capabilities for building secure, private, and responsible generative AI applications. The single API access allows flexibility in using different FMs and upgrading to the latest model versions with minimal code changes.

The Travel Planner AI App running in the browser

Prerequisites

  • An AWS account. Note that Amplify is part of the AWS Free Tier.
  • Node.js v18.17 or later
  • npm v9 or later
  • git v2.14.1 or later
  • A text editor, for this guide we will use VSCode, but you can use your preferred IDE.
  • Access to the Claude 3 Haiku model on Amazon Bedrock

Cloning the repo

Step 1: Navigate to the repository on AWS Samples and fork it to your GitHub repositories

Fork the repository on AWS samples

Step 2: Clone the app by running the command below in your terminal


git clone https://github.com/<YOUR_GITHUB>/travel-personal-assistant.git

Step 3: Access the newly cloned repository in VSCode by executing the commands below in your terminal.


cd travel-personal-assistant
code . -r

VSCode will open the repository folder, including the Amplify folder, which contains the backend details that we’ll discuss in the next section.

Open the cloned repository using VSCode

Step 4: Install the required packages including the Amplify packages by running the command below:


npm i 

The Amplify Backend

In the final app (as seen in the GIF at the beginning of the post), users will initiate the conversation by submitting their travel questions (e.g., “I want to go to Ireland in December for 7 days”). The code is in the repository you cloned. Here, we’ll go over the key steps to connect your Amplify app with Amazon Bedrock.

In the repository, you’ll find an amplify folder containing direcotores for auth, data and functions resources

The Amplify folder in the project

By default, the auth resource configured in the amplify/auth/resource.ts file to use the email as a sign up mechanism for the users.

import { defineAuth } from '@aws-amplify/backend';

/**
 * Define and configure your auth resource
 * @see https://docs.amplify.aws/gen2/build-a-backend/auth
 */
export const auth = defineAuth({
  loginWith: {
    email: true,
  },
});

In the amplify/data/resource.ts file, we’ve defined a GraphQL query chat that returns a string and expects JSON string argument conversation that will include the entire conversation from our interaction with the travel assistant. Authorization is setup using .authorization((allow) => [allow.authenticated()]) to only allow authenticated users to access this query. The .handler(a.handler.function(personalAssistantFunction)) line sets up personalAssistantFunction as a a custom handler for this query

import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
import { personalAssistantFunction } from "../functions/personal-assistant/resource";

const schema = a.schema({
  chat: a
    .query()
    .arguments({
      conversation: a.json().required(),
    })
    .returns(a.string())
    .authorization((allow) => [allow.authenticated()])
    .handler(a.handler.function(personalAssistantFunction)),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "userPool",
  },
});

The personalAssistantFunction lambda function is defined in and exported from the file amplify/functions/personal-assistant/resource.ts Here we also set an environment variable MODEL_ID to Claude 3 Haiku

import { defineFunction } from "@aws-amplify/backend";

export const MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0";

export const personalAssistantFunction = defineFunction({
  entry: "./handler.ts",
  environment: {
    MODEL_ID,
  },
  timeoutSeconds: 30,
  runtime: 20,
});

The file amplify/functions/personal-assistant/handler.ts contains the implementation of the personalAssistantFunction handler. Here we initialize a Bedrock run time client and utilizes the query’s input parameters, i.e., conversation and a systemPrompt constant (to provide context, instructions, and guidelines to Amazon Bedrock on how to respond.), to generate a input object and use it to create a ConverseCommand, then send it to Amazon Bedrock using the run time client. We verify the response for errors and return it as JSON string

import {
  BedrockRuntimeClient,
  ConverseCommandInput,
  ConverseCommand,
} from "@aws-sdk/client-bedrock-runtime";
import type { Handler } from "aws-lambda";

// Constants
const AWS_REGION = process.env.AWS_REGION;
const MODEL_ID = process.env.MODEL_ID;

// Configuration
const INFERENCE_CONFIG = {
  maxTokens: 1000,
  temperature: 0.5,
};

// Initialize Bedrock Runtime Client
const client = new BedrockRuntimeClient({ region: AWS_REGION });

export const handler: Handler = async (event) => {
  const { conversation } = event.arguments;

  const SYSTEM_PROMPT = `
  To create a personalized travel planning experience, greet users warmly and inquire about their travel preferences 
  such as destination, dates, budget, and interests. Based on their input, suggest tailored itineraries that include 
  popular attractions, local experiences, and hidden gems, along with accommodation options across various price 
  ranges and styles. Provide transportation recommendations, including flights and car rentals, along with estimated 
  costs and travel times. Recommend dining experiences that align with dietary needs, and share insights on local 
  customs, necessary travel documents, and packing essentials. Highlight the importance of travel insurance, offer 
  real-time updates on weather and events, and allow users to save and modify their itineraries. Additionally, 
  provide a budget tracking feature and the option to book flights and accommodations directly or through trusted 
  platforms, all while maintaining a warm and approachable tone to enhance the excitement of trip planning.
`;

  const input = {
    modelId: MODEL_ID,
    system: [{ text: SYSTEM_PROMPT }],
    messages: conversation,
    inferenceConfig: INFERENCE_CONFIG,
  } as ConverseCommandInput;

  try {
    const command = new ConverseCommand(input);
    const response = await client.send(command);

    if (!response.output?.message) {
      throw new Error("No message in the response output");
    }

    return JSON.stringify(response.output.message);
  } catch (error) {
    console.error("Error in chat handler:", error);
    throw error; // Re-throw to be handled by AWS Lambda
  }
};

In the amplify/backend.ts file, we add a new policy to the principal of the personalAssistantFunction function using the addToPrincipalPolicy method. The policy statement specifies the allowed resources and actions. In this case, the resource is the AWS ARN (Amazon Resource Name) for the Claude 3 Haiku model, and the permitted action is bedrock:InvokeModel.

import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { personalAssistantFunction, MODEL_ID } from "./functions/personal-assistant/resource";

export const backend = defineBackend({
  auth,
  data,
  personalAssistantFunction,
});

backend.personalAssistantFunction.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    effect: Effect.ALLOW,
    actions: ["bedrock:InvokeModel"],
    resources: [
      `arn:aws:bedrock:*::foundation-model/${MODEL_ID}`,
    ],
  })
);

When you run the app (as demonstrated in the next section), a file named amplify_outputs.json is generated automatically. This file holds your API’s endpoint details. In the src/app/amplify-utils.ts, we initialize and configure the Amplify client library as shown below. Then, we create a data client to facilitate requests to the Amplify backend.

import { generateClient } from "aws-amplify/api";
import { Schema } from "../../amplify/data/resource";
import { Amplify } from "aws-amplify";
import outputs from "../../amplify_outputs.json";

Amplify.configure(outputs);

export const amplifyClient = generateClient<Schema>();

In the src/app/layout.tsx file, we wrapped the app’s content in an AuthWrapper component (src/app/components/AuthWrapper.tsx) that utilizes the Amplify Authenticator. This component scaffolds a complete user authentication flow, enabling users to sign up, sign in, reset their passwords, and confirm sign-in for multi-factor authentication (MFA).

"use client";
import { Authenticator } from "@aws-amplify/ui-react";
import { ReactNode } from "react";

interface AuthWrapperProps {
  children: ReactNode;
}

export function AuthWrapper({ children }: AuthWrapperProps) {
  return <Authenticator>{children}</Authenticator>;
}

The app uses the src/app/page.tsx file to present a component (src/app/components/Chat.tsx) to users for chatting with the AI assistant.

"use client";
import { Button, View, Heading, Flex, Text } from "@aws-amplify/ui-react";
import Chat from "@/components/Chat";
import { useAuthenticator } from "@aws-amplify/ui-react";

export default function Home() {
  const { user, signOut } = useAuthenticator();

  return (
    <View className="app-container">
      <Flex
        as="header"
        justifyContent="space-between"
        alignItems="center"
        padding="1rem"
      >
        <Text fontWeight="bold">{user?.signInDetails?.loginId}</Text>
        <Heading level={3}>Travel Personal Assistant</Heading>
        <Button onClick={signOut} size="small" variation="destructive">
          Sign out
        </Button>
      </Flex>
      <View as="main">
        <Chat />
      </View>
    </View>
  );
}

In the src/app/components/Chat.tsx file, we created a simple chat interface to facilitate the conversation with the AI assistant. We present a form to capture the user’s message. once submitted, we use the function fetchChatResponse to invoke the chat query, passing the current conversation and the user’s new message as a parameter to retrieve the assistant response and update the conversation.

import React, { ChangeEvent, useEffect, useRef, useState } from "react";
import { Button, Placeholder, View } from "@aws-amplify/ui-react";
import { amplifyClient } from "@/app/amplify-utils";

// Types
type Message = {
  role: string;
  content: { text: string }[];
};

type Conversation = Message[];

export function Chat() {
  const [conversation, setConversation] = useState<Conversation>([]);
  const [inputValue, setInputValue] = useState("");
  const [error, setError] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const messagesRef = useRef(null);

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setError("");
    setInputValue(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (inputValue.trim()) {
      const message = setNewUserMessage();
      fetchChatResponse(message);
    }
  };

  const fetchChatResponse = async (message: Message) => {
    setInputValue("");
    setIsLoading(true);

    try {
      const { data, errors } = await amplifyClient.queries.chat({
        conversation: JSON.stringify([...conversation, message]),
      });

      if (!errors && data) {
        setConversation((prevConversation) => [
          ...prevConversation,
          JSON.parse(data),
        ]);
      } else {
        throw new Error(errors?.[0].message || "An unknown error occurred.");
      }
    } catch (err) {
      setError((err as Error).message);
      console.error("Error fetching chat response:", err);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    const lastMessage = conversation[conversation.length - 1];
    console.log("lastMessage", lastMessage);
    (
      messagesRef.current as HTMLDivElement | null
    )?.lastElementChild?.scrollIntoView();
  }, [conversation]);

  const setNewUserMessage = (): Message => {
    const newUserMessage: Message = {
      role: "user",
      content: [{ text: inputValue }],
    };
    setConversation((prevConversation) => [
      ...prevConversation,
      newUserMessage,
    ]);

    setInputValue("");
    return newUserMessage;
  };

  return (
    <View className="chat-container">
      <View className="messages" ref={messagesRef}>
        {conversation.map((msg, index) => (
          <View key={index} className={`message ${msg.role}`}>
            {msg.content[0].text}
          </View>
        ))}
      </View>
      {isLoading && (
        <View className="loader-container">
          <p>Thinking...</p>

          <Placeholder size="large" />
        </View>
      )}

      <form onSubmit={handleSubmit} className="input-container">
        <input
          name="prompt"
          value={inputValue}
          onChange={handleInputChange}
          placeholder="Type your message..."
          className="input"
          type="text"
        />
        <Button
          type="submit"
          className="send-button"
          isDisabled={isLoading}
          loadingText="Sending..."
        >
          Send
        </Button>
      </form>

      {error ? <View className="error-message">{error}</View> : null}
    </View>
  );
}

export default Chat;

Running the App

Step 1: Amplify provides each developer with a personal cloud sandbox environment, offering isolated development spaces for rapid building, testing, and iteration. To initiate a cloud sandbox environment, open a new terminal window and execute the following command:


npx amplify sandbox

Step 2: Execute the command below to start a localhost development server.


npm run dev

The Travel Planner AI App running in the browser

Step 3: After testing the app, you can terminate the sandbox session via Ctrl+c . Choose Y for the prompt to delete all the resources in the sandbox environment.


[Sandbox] Watching for file changes...
File written: amplify_outputs.json
? Would you like to delete all the resources in your sandbox environment (This cannot be undone)? (y/N) Y

Deploy the App

Now that your app is functioning correctly, let’s deploy and host it on Amplify. Amplify provides a fully managed hosting service with built-in CI/CD, simplifying the setup of production and staging environments using Git branches. In Gen 2, each Git branch in your repository corresponds directly to a fullstack branch in Amplify.

Step 1: Sign in to the AWS console and select your desired AWS Region. Open the AWS Amplify console and choose Create new app

Amplify homepage on AWS console

Step 2: On the Start building with Amplify page, for Deploy your app, select GitHub, and select Next.

select GitHub, and select Next.

Step 3: When prompted, authenticate with GitHub. You will be automatically redirected back to the Amplify console. Choose the repository and main branch of the app. Then select Next.

Choose the repository and the branch from the dropdown lists

Step 4: Review the settings and select Next.

Review the settings

Step 5: Lastly, click on the “Save and deploy” button to initiate the deployment process.

click on the "Save and deploy" button

Step 6: Wait for the deployment process to finish, and the you can use the Visit deployed Url button to open the web app.

Click the Visit deployed Url button to open the web app

The Travel Planner AI App running in the browser

Clean up resources

Now that you’ve finished this walkthrough, you can delete the backend resources to prevent unexpected costs by deleting the app from the Amplify console, as shown below.

Click the Delete app button

Conclusion

Congratulations! You’ve successfully used AWS Amplify Gen 2 and Amazon Bedrock to develop an AI-powered Travel Assistant App. Additionally, you’ve deployed the app on AWS using Amplify Hosting. To get started with Amplify Gen 2, try out the Quickstart tutorial, and join the Community Discord to leave any feedback or feature requests.

Author:

Mo Malaka

Mo Malaka is a Senior Solution Architect on the AWS Amplify Team. The Solution Architecture team educates developers regarding products and offerings, and acts as the primary point of contact for assistance and feedback. Mo enjoys using technology to solve problems and making people’s lives easier. You can find Mo on YouTube or on Twitter.