diff --git a/cspell.json b/cspell.json index abb83dd527d..3bcd96969a0 100644 --- a/cspell.json +++ b/cspell.json @@ -1614,7 +1614,8 @@ "ampx", "autodetection", "jamba", - "knowledgebases" + "knowledgebases", + "rehype" ], "flagWords": ["hte", "full-stack", "Full-stack", "Full-Stack", "sudo"], "patterns": [ diff --git a/package.json b/package.json index bb353d977c0..92083486721 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "private": true, "dependencies": { "@aws-amplify/amplify-cli-core": "^4.3.9", - "@aws-amplify/ui-react": "^6.3.1", + "@aws-amplify/ui-react": "^6.7.0", + "@aws-amplify/ui-react-ai": "^1.0.0", "@docsearch/react": "3", "ajv": "^8.16.0", "aws-amplify": "^6.0.9", diff --git a/public/images/user.jpg b/public/images/user.jpg new file mode 100644 index 00000000000..a4e7612efcb Binary files /dev/null and b/public/images/user.jpg differ diff --git a/src/components/AI/index.tsx b/src/components/AI/index.tsx new file mode 100644 index 00000000000..324a0c1a64b --- /dev/null +++ b/src/components/AI/index.tsx @@ -0,0 +1,64 @@ +import { Avatar } from '@aws-amplify/ui-react'; +import { ConversationMessage } from '@aws-amplify/ui-react-ai'; +import { AmplifyLogo } from '@/components/GlobalNav/components/icons'; + +export const UserAvatar = () => { + return ; +}; + +export const AssistantAvatar = () => { + return ( + + + + ); +}; + +export const MESSAGES: ConversationMessage[] = [ + { + conversationId: 'foobar', + id: '1', + content: [{ text: 'Hello' }], + role: 'user' as const, + createdAt: new Date(2023, 4, 21, 15, 23).toISOString() + }, + { + conversationId: 'foobar', + id: '2', + content: [ + { + text: 'Hello! I am your virtual assistant how may I help you?' + } + ], + role: 'assistant' as const, + createdAt: new Date(2023, 4, 21, 15, 24).toISOString() + } +]; + +export const MESSAGES_RESPONSE_COMPONENTS: ConversationMessage[] = [ + { + conversationId: 'foobar', + id: '1', + content: [{ text: 'Whats the weather in San Jose?' }], + role: 'user' as const, + createdAt: new Date(2023, 4, 21, 15, 23).toISOString() + }, + { + conversationId: 'foobar', + id: '2', + content: [ + { + text: 'Let me get the weather for San Jose for you.' + }, + { + toolUse: { + name: 'AMPLIFY_UI_WeatherCard', + input: { city: 'San Jose' }, + toolUseId: '1234' + } + } + ], + role: 'assistant' as const, + createdAt: new Date(2023, 4, 21, 15, 24).toISOString() + } +]; diff --git a/src/components/UIWrapper/UWrapper.tsx b/src/components/UIWrapper/UWrapper.tsx new file mode 100644 index 00000000000..e0ca6f94bd8 --- /dev/null +++ b/src/components/UIWrapper/UWrapper.tsx @@ -0,0 +1,29 @@ +import { + createTheme, + defaultDarkModeOverride, + ThemeProvider, + View +} from '@aws-amplify/ui-react'; +import * as React from 'react'; +import { LayoutContext } from '../Layout'; + +const theme = createTheme({ + name: 'default-amplify-ui-theme', + overrides: [defaultDarkModeOverride] +}); + +export const UIWrapper = ({ children }: React.PropsWithChildren) => { + const { colorMode } = React.useContext(LayoutContext); + + return ( + + + {children} + + + ); +}; diff --git a/src/components/UIWrapper/index.ts b/src/components/UIWrapper/index.ts new file mode 100644 index 00000000000..984a3cb3738 --- /dev/null +++ b/src/components/UIWrapper/index.ts @@ -0,0 +1 @@ +export { UIWrapper } from './UWrapper'; diff --git a/src/directory/directory.mjs b/src/directory/directory.mjs index d6337968899..601e28c3ae1 100644 --- a/src/directory/directory.mjs +++ b/src/directory/directory.mjs @@ -753,6 +753,9 @@ export const directory = { { path: 'src/pages/[platform]/ai/conversation/index.mdx', children: [ + { + path: 'src/pages/[platform]/ai/conversation/ai-conversation/index.mdx' + }, { path: 'src/pages/[platform]/ai/conversation/history/index.mdx' }, diff --git a/src/pages/[platform]/ai/conversation/ai-conversation/index.mdx b/src/pages/[platform]/ai/conversation/ai-conversation/index.mdx new file mode 100644 index 00000000000..cc5b19687ea --- /dev/null +++ b/src/pages/[platform]/ai/conversation/ai-conversation/index.mdx @@ -0,0 +1,371 @@ +import { Card, Text } from '@aws-amplify/ui-react'; +import { AIConversation } from '@aws-amplify/ui-react-ai' +import { getCustomStaticPath } from "@/utils/getCustomStaticPath"; +import { UIWrapper } from '@/components/UIWrapper' +import { UserAvatar,AssistantAvatar, MESSAGES, MESSAGES_RESPONSE_COMPONENTS } from '@/components/AI' + +export const meta = { + title: "", + description: + "The AIConversation component is a customizable chat interface built for the Amplify AI kit", + platforms: [ + "nextjs", + "react", + ], +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta, + }, + }; +} + + + {}} +/> + +*Note: the example is a mocked component and not hooked up to a live backend* + +## Introduction + +The `` component is highly customizable to fit into any application. The component is built so that it works with the `useAIConversation` hook. The hook manages the state and lifecycle of the component. The component by itself is just a renderer for the conversation state, which the hook provides. The `` component requires some props: +* `messages` an array of the messages in the conversation +* `handleSendMessage` a handler that is called when a user message is sent. + +The `useAIConversation` hook provides these values and manages the messages state as user messages are sent and assistant responses are streamed back. + +```tsx title="Mock conversation" +import { AIConversation } from '@aws-amplify/ui-react-ai'; + +export default function Chat() { + return ( + {}} + /> + ) +} +``` + +The code above won't really do much, but if you wanted to play around with the component or visually test how it will look, you can do that passing in your own set of messages. + + +## Getting started + +Make sure to first follow our [getting started guide for the Amplify AI kit](/[platform]/ai/set-up-ai) to set up your Amplify AI backend. + +Conversations required a logged in user, so we recommend using the [``](/[platform]/build-a-backend/auth/connect-your-frontend/using-the-authenticator/) component to easily add authentication flows to your app. + + + +```tsx title="src/App.tsx" +import { Amplify } from 'aws-amplify'; +import { generateClient } from "aws-amplify/api"; +import { Authenticator } from "@aws-amplify/ui-react"; +import { AIConversation, createAIHooks } from '@aws-amplify/ui-react-ai'; +import '@aws-amplify/ui-react/styles.css'; +import outputs from "../amplify_outputs.json"; +import { Schema } from "../amplify/data/resource"; + +Amplify.configure(outputs); + +const client = generateClient({ authMode: "userPool" }); +const { useAIConversation } = createAIHooks(client); + +export default function App() { + const [ + { + data: { messages }, + isLoading, + }, + handleSendMessage, + ] = useAIConversation('chat'); + // 'chat' is based on the key for the conversation route in your schema. + + return ( + + + + ); +} +``` + + + +## Formatting Markdown + +LLMs can respond with markdown. The `` component does not have built-in markdown rendering, but does allow for you to pass in your own markdown renderer. + +```tsx +import ReactMarkdown from 'react-markdown'; + + {text} + }} +/> +``` + +The `messageRenderer` property lets you customize how markdown is rendered within the chat according to your application's needs. The example below demonstrates how to add code syntax highlighting by using `ReactMarkdown` with `rehypeHighlight`. + +```tsx +import ReactMarkdown from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; + + ( + + {text} + + ) + }} +/> +``` + +## Rendering images + +The `` component renders images in the conversation history by default. You can also customize how images are rendered with `messageRenderer`, similar to the text example above. + +```tsx +// Note: the image in a message comes in as a byte array +// you will need to convert this to base64 +function convertBufferToBase64( + buffer: ArrayBuffer, + format: 'png' | 'jpeg' | 'gif' | 'webp' +): string { + const base64string = Buffer.from(new Uint8Array(buffer)).toString('base64'); + return `data:image/${format};base64,${base64string}`; +} + + ( + + ), + }} +/> +``` + +## Welcome message + +You can have the `` component display a welcome message when a user starts a new conversation. + + + {}} + welcomeMessage={ + + I am your virtual assistant, ask me any questions you like! + + } +/> + + +```tsx + + I am your virtual assistant, ask me any questions you like! + + } +/> +``` + +The welcome message will disappear once a message has been sent. + +## Customizing the timestamp + +All messages have a timestamp associated with them that are displayed next to the username. To customize how the timestamp displays you can pass a custom text formatter function called `getMessageTimestampText` into the `displayText` property on the `` component. This function will receive a `Date` object as its argument and should return a string. + +Browsers have a really nice built-in date/time formatter you can use called [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat). + + + {}} + displayText={{ + getMessageTimestampText: (date) => new Intl.DateTimeFormat('en-US', { + timeStyle: 'short', + hour12: true, + timeZone: 'UTC' + }).format(date) + }} +/> + + +```tsx + new Intl.DateTimeFormat('en-US', { + timeStyle: 'short', + hour12: true, + timeZone: 'UTC' + }).format(date) + }} +/> +``` + +You could also return an empty string if you wanted to hide the timestamps altogether. + +## Attachments + +Some of the newer LLMs like the Claude 3 family of models from Anthropic support multi-modal input, so you can send images in your message to the model and it can respond based on the messages. To enable this functionality in the component, there is an `allowAttachments` prop you can enable. + +There are some limitations on the filetype and size of the images attached. The file size for each file should be below 400kb when base64 encoded. Also the currently supported file types are: png, jpg, gif, and webp. + + + + {}} + allowAttachments +/> + + +```tsx + +``` + + +## Avatars + +You can customize the usernames and avatars used in the `AIConversation` component by using the `avatars` prop. This lets you control what your AI assistant looks like in the chat and what your user's username and avatar are. + +There are 2 avatars, `user` and `ai`, and each have a `username` and `avatar` attribute. The `avatar` is a React Node and the `username` is a string. + + + {}} + allowAttachments + avatars={{ + user: { + avatar: , + username: "danny" + }, + ai: { + avatar: , + username: "Amplify assistant" + } + }} +/> + + + +```tsx +, + username: "danny", + }, + ai: { + avatar: , + username: "Amplify assistant" + } + }} +/> +``` + + +## Response components + +Response components are a way to define custom UI components that the LLM can respond with in the conversation. This creates a richer experience than just text responses so the conversation can be more interactive and engaging. To define a response component you need any React component and give it a name, description, and define the props the LLM should know. + + + {}} + responseComponents={{ + WeatherCard: { + description: + 'Used to display the weather of a given city to the user', + component: ({ city }) => { + return ( + + {city} + + ); + }, + props: { + city: { + type: 'string', + required: true, + }, + }, + }, + }} +/> + + + +```tsx + { + return {city}; + }, + props: { + city: { + type: 'string', + required: true, + }, + }, + }, + }} +/> +``` + +Response components are just plain React components; they can have their own interactive state, fetch data, update shared state, or really anything you can think of. You can pair response components with [data tools](/[platform]/ai/conversation/tools), so the LLM can query for some data and then use a component to display that data. Or your response component could fetch data itself. + +### Adding a fallback + +Because response components are defined at runtime and conversation histories are stored in a database, there can be times when there is a response component in the message history that the current application does not have. Response components are saved in the message history as a "toolUse" block, similar to how an LLM would respond when it wants to call a tool. The toolUse block contains the name of the component, and the props the LLM wanted to pass to the component. The LLM is never directly sending UI code, but rather an abstract representation of what it wants to render. + +If the AIConversation component receives a response component message for a response component that was not given to it, by default it will just not render anything. However if you want to add a fallback component if no component is found based on the name, you can use the `FallbackResponseComponent` prop. You can think of this like a 404 page for response components. + + + {}} + FallbackResponseComponent={(props) => {JSON.stringify(props, null, 2)}} +/> + + +```tsx + ( + {JSON.stringify(props, null, 2)} + )} +/> +``` + + + diff --git a/yarn.lock b/yarn.lock index 25fb27bfcfc..a97e5c13c88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -350,24 +350,44 @@ fast-xml-parser "^4.4.1" tslib "^2.5.0" -"@aws-amplify/ui-react-core@3.0.22": - version "3.0.22" - resolved "https://registry.npmjs.org/@aws-amplify/ui-react-core/-/ui-react-core-3.0.22.tgz" - integrity sha512-uL5jspqvTZhpqH1inPV3ifvrzIVgIIriXPgjz4BaceDm+1X03Hc3tfq5TiUKW8PdpuWF6riXXBP3MEFsk29OGw== +"@aws-amplify/ui-react-ai@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react-ai/-/ui-react-ai-1.0.0.tgz#db6dede6b42685f9c03293374a1aaf195de7f02f" + integrity sha512-Y+ezPjjdUajEjN+naWhaBn7TQ4nVEtB0EylGwI8YrLKHFQVJaEGBWvvw9mfiUl3krWv+utP+EhagTM8Zk9cMBg== + dependencies: + "@aws-amplify/ui" "^6.6.6" + "@aws-amplify/ui-react" "^6.6.0" + "@aws-amplify/ui-react-core" "^3.0.30" + +"@aws-amplify/ui-react-core@3.0.30", "@aws-amplify/ui-react-core@^3.0.30": + version "3.0.30" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react-core/-/ui-react-core-3.0.30.tgz#e86346d5293bfa7f22aae9832fc0bef94ea64a83" + integrity sha512-3AaUSC1Mg+yr7TqHfp34QpP6ICjQl9wUR+x7KxsETc7m5tCv4ANGXOD7qaADCi3CEw2IChBFuVu4NFKsGJbjqQ== dependencies: - "@aws-amplify/ui" "6.4.1" + "@aws-amplify/ui" "6.6.6" "@xstate/react" "^3.2.2" lodash "4.17.21" react-hook-form "^7.43.5" xstate "^4.33.6" -"@aws-amplify/ui-react@^6.3.1": - version "6.3.1" - resolved "https://registry.npmjs.org/@aws-amplify/ui-react/-/ui-react-6.3.1.tgz" - integrity sha512-n/wTjMYYuAhpgpuQ4o+9+IgvNsdjGqbfaA4bAXE7b1apIOfM8HTaO2zant85x+geDl7rbaa1y/z62T4H9AksMQ== +"@aws-amplify/ui-react-core@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react-core/-/ui-react-core-3.1.0.tgz#236ecf10194a27dc57fc983c679ab1de19b8a00d" + integrity sha512-Nime3qjJRQyfRDDA4bnAaFVzRfEBddZFP8NhVIb13z7Uw0XoPQzX+dXwuRW+Bjt2FJnlIUHh7Cfkt0m4PedRHQ== dependencies: - "@aws-amplify/ui" "6.4.1" - "@aws-amplify/ui-react-core" "3.0.22" + "@aws-amplify/ui" "6.7.0" + "@xstate/react" "^3.2.2" + lodash "4.17.21" + react-hook-form "^7.43.5" + xstate "^4.33.6" + +"@aws-amplify/ui-react@^6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react/-/ui-react-6.6.0.tgz#1d10f435286fcac5e07330e318a0e30c0c84de94" + integrity sha512-BHth+/CBZ8XE0IBpq+L/7mWbvGRIQxjtOmW50593hW7PvtaSXXDzPHZXRQ5nRhWs1anhxNWDiv4BKuiey6YpOw== + dependencies: + "@aws-amplify/ui" "6.6.6" + "@aws-amplify/ui-react-core" "3.0.30" "@radix-ui/react-direction" "1.0.0" "@radix-ui/react-dropdown-menu" "1.0.0" "@radix-ui/react-slider" "1.0.0" @@ -376,10 +396,34 @@ qrcode "1.5.0" tslib "^2.5.2" -"@aws-amplify/ui@6.4.1": - version "6.4.1" - resolved "https://registry.npmjs.org/@aws-amplify/ui/-/ui-6.4.1.tgz" - integrity sha512-0rGGJjnd60gZNhjqDepk3VpCpzyJDE2+xevVg0iqM8APKpyQ9XRWisNLIvglQy7p/3CauXdw8U0NZVcu29Yhrw== +"@aws-amplify/ui-react@^6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react/-/ui-react-6.7.0.tgz#a3fc28980feee01f319448c360307a5ccc65c7f3" + integrity sha512-3H97gz43+iaVNPqkQIiFj4Ko7zJLyMGtZScfNyt6PK4Ntuit5ZP6hc+Z7BtNsNEkfnAiGL1BiNOuD1IBfsnifw== + dependencies: + "@aws-amplify/ui" "6.7.0" + "@aws-amplify/ui-react-core" "3.1.0" + "@radix-ui/react-direction" "1.0.0" + "@radix-ui/react-dropdown-menu" "1.0.0" + "@radix-ui/react-slider" "1.0.0" + "@xstate/react" "^3.2.2" + lodash "4.17.21" + qrcode "1.5.0" + tslib "^2.5.2" + +"@aws-amplify/ui@6.6.6", "@aws-amplify/ui@^6.6.6": + version "6.6.6" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui/-/ui-6.6.6.tgz#adf39ea025ed2f35e46bb01323f8d73142b41f59" + integrity sha512-4fBMO5+saXaAgBwhYbQIgudVcK1B9oHuG3WizMpImcYUbB5aL4B6NhFtmSz3DBBqAeqrZHUuUV7gWibU5JxAGQ== + dependencies: + csstype "^3.1.1" + lodash "4.17.21" + tslib "^2.5.2" + +"@aws-amplify/ui@6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@aws-amplify/ui/-/ui-6.7.0.tgz#f31da1515a25c2fac3d9e17c9638e619a988baf8" + integrity sha512-6hByYfFBQRjsFMoVGdCWMSdo7rwMgz6rxxdWV0xuHb4j3tsPEI9ZhRXG0Z1ivtQFAM3Uaz0D3hcg1kp6QFdCFg== dependencies: csstype "^3.1.1" lodash "4.17.21" @@ -4574,7 +4618,7 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" -cross-spawn@^6.0.0, "cross-spawn@^6.0.6 || ^7.0.5", cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^6.0.0, cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==