From 9ab56f499f4596062fe4614a016efc3dfc88ef03 Mon Sep 17 00:00:00 2001 From: Danny Banks Date: Wed, 27 Nov 2024 15:21:34 -0800 Subject: [PATCH] feat(ai) add attachment validations (#6211) --- .changeset/honest-beans-suffer.md | 16 ++++ docs/src/components/ComponentsMetadata.ts | 5 ++ .../ai/ai-conversation/attachments.page.tsx | 55 ++++++++++++ packages/react-ai/jest.config.ts | 6 +- .../AIConversation/AIConversationProvider.tsx | 8 +- .../__snapshots__/displayText.test.tsx.snap | 2 + .../AIConversation/__tests__/utils.test.tsx | 89 +++++++++++++++++++ .../context/AttachmentContext.tsx | 34 +++++-- .../context/ControlsContext.tsx | 7 +- .../context/ConversationInputContext.tsx | 11 ++- .../AIConversation/context/index.ts | 7 +- .../components/AIConversation/displayText.ts | 10 +++ .../src/components/AIConversation/types.ts | 2 + .../src/components/AIConversation/utils.ts | 71 ++++++++++++--- .../views/Controls/FormControl.tsx | 65 ++++++++++++-- .../views/default/Attachments.tsx | 4 +- .../AIConversation/views/default/Form.tsx | 36 ++++---- .../views/default/__tests__/Form.spec.tsx | 32 ++++--- .../default/__tests__/PromptList.test.tsx | 6 +- .../ui/src/theme/components/aiConverstion.ts | 1 + .../AIConversation/aiConversation.scss | 11 ++- .../types/primitives/componentClassName.ts | 1 + 22 files changed, 399 insertions(+), 80 deletions(-) create mode 100644 .changeset/honest-beans-suffer.md create mode 100644 examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx diff --git a/.changeset/honest-beans-suffer.md b/.changeset/honest-beans-suffer.md new file mode 100644 index 00000000000..08d934dfd6b --- /dev/null +++ b/.changeset/honest-beans-suffer.md @@ -0,0 +1,16 @@ +--- +"@aws-amplify/ui-react-ai": minor +"@aws-amplify/ui": patch +--- + +feat(ai) add attachment validations + +The current limitations on the Amplify AI kit for attachments is 400kb (of base64'd size) per image, and 20 images per message are now being enforced before the message is sent. +These limits can be adjusted via props as well. + +```tsx + +``` diff --git a/docs/src/components/ComponentsMetadata.ts b/docs/src/components/ComponentsMetadata.ts index 7948e9c9b07..6ab6b509cb0 100644 --- a/docs/src/components/ComponentsMetadata.ts +++ b/docs/src/components/ComponentsMetadata.ts @@ -135,6 +135,11 @@ export const ComponentsMetadata: ComponentClassNameItems = { components: ['AIConversation'], description: 'Class applied to the form element', }, + AIConversationFormError: { + className: ComponentClassName.AIConversationFormError, + components: ['AIConversation'], + description: 'Class applied to the error message of the form', + }, AIConversationFormAttach: { className: ComponentClassName.AIConversationFormAttach, components: ['AIConversation'], diff --git a/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx b/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx new file mode 100644 index 00000000000..51d7857ffb1 --- /dev/null +++ b/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Amplify } from 'aws-amplify'; +import { signOut } from 'aws-amplify/auth'; +import { createAIHooks, AIConversation } from '@aws-amplify/ui-react-ai'; +import { generateClient } from 'aws-amplify/api'; +import '@aws-amplify/ui-react/styles.css'; + +import outputs from './amplify_outputs'; +import type { Schema } from '@environments/ai/gen2/amplify/data/resource'; +import { Authenticator, Button, Card, Flex } from '@aws-amplify/ui-react'; + +const client = generateClient({ authMode: 'userPool' }); +const { useAIConversation } = createAIHooks(client); + +Amplify.configure(outputs); + +function Chat() { + const [ + { + data: { messages }, + isLoading, + }, + sendMessage, + ] = useAIConversation('pirateChat'); + + return ( + + ); +} + +export default function Example() { + return ( + + + + + + + + + ); +} diff --git a/packages/react-ai/jest.config.ts b/packages/react-ai/jest.config.ts index 02ef2da2db1..746f800dcf7 100644 --- a/packages/react-ai/jest.config.ts +++ b/packages/react-ai/jest.config.ts @@ -14,9 +14,9 @@ const config: Config = { coverageThreshold: { global: { branches: 68, - functions: 78, - lines: 87, - statements: 87, + functions: 77, + lines: 86, + statements: 86, }, }, testPathIgnorePatterns: [], diff --git a/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx b/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx index 99482b042de..36ab17e2d4b 100644 --- a/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx +++ b/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx @@ -37,6 +37,8 @@ export const AIConversationProvider = ({ displayText, handleSendMessage, isLoading, + maxAttachmentSize, + maxAttachments, messages, messageRenderer, responseComponents, @@ -60,7 +62,11 @@ export const AIConversationProvider = ({ - + { @@ -57,3 +58,91 @@ describe('getImageTypeFromMimeType', () => { expect(getImageTypeFromMimeType('image/webp')).toBe('webp'); }); }); + +describe('attachmentsValidator', () => { + // Helper function to create mock files + const createMockFile = (size: number, name = 'test.txt'): File => { + const buffer = new ArrayBuffer(size); + File.prototype.arrayBuffer = jest.fn().mockResolvedValueOnce(buffer); + return new File([buffer], name, { type: 'text/plain' }); + }; + + it('should accept files within size limit', async () => { + const files = [createMockFile(100)]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(1); + expect(result.rejectedFiles).toHaveLength(0); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should reject files exceeding size limit', async () => { + const files = [createMockFile(2000)]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(0); + expect(result.rejectedFiles).toHaveLength(1); + expect(result.hasMaxAttachmentSizeError).toBeTruthy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should handle mixed valid and invalid file sizes', async () => { + const files = [ + createMockFile(500), + createMockFile(2000), + createMockFile(800), + ]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(2); + expect(result.rejectedFiles).toHaveLength(1); + expect(result.hasMaxAttachmentSizeError).toBeTruthy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should enforce maximum number of attachments', async () => { + const files = [ + createMockFile(100), + createMockFile(200), + createMockFile(300), + createMockFile(400), + ]; + const result = await attachmentsValidator({ + files, + maxAttachments: 2, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(2); + expect(result.rejectedFiles).toHaveLength(2); + expect(result.hasMaxAttachmentsError).toBeTruthy(); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + }); + + it('should handle empty file list', async () => { + const files: File[] = []; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(0); + expect(result.rejectedFiles).toHaveLength(0); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); +}); diff --git a/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx b/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx index 8a51e7dd928..3195ad6c3c8 100644 --- a/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx @@ -1,16 +1,36 @@ import * as React from 'react'; +import { AIConversationInput } from '../types'; -export const AttachmentContext = React.createContext(false); +export interface AttachmentContextProps + extends Pick< + AIConversationInput, + 'allowAttachments' | 'maxAttachments' | 'maxAttachmentSize' + > {} + +export const AttachmentContext = React.createContext< + Required +>({ + allowAttachments: false, + // We save attachments as base64 strings into dynamodb for conversation history + // DynamoDB has a max size of 400kb for records + // This can be overridden so cutsomers could provide a lower number + // or a higher number if in the future we support larger sizes. + maxAttachmentSize: 400_000, + maxAttachments: 20, +}); export const AttachmentProvider = ({ children, - allowAttachments, -}: { - children?: React.ReactNode; - allowAttachments?: boolean; -}): JSX.Element => { + allowAttachments = false, + maxAttachmentSize = 400_000, + maxAttachments = 20, +}: React.PropsWithChildren): JSX.Element => { + const providerValue = React.useMemo( + () => ({ maxAttachmentSize, maxAttachments, allowAttachments }), + [maxAttachmentSize, maxAttachments, allowAttachments] + ); return ( - + {children} ); diff --git a/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx b/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx index eeba5252acb..717dd38d5d7 100644 --- a/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ConversationInputContext } from './ConversationInputContext'; +import { ConversationInputContextProps } from './ConversationInputContext'; import { SuggestedPrompt } from '../types'; import { ConversationMessage } from '../../../types'; @@ -9,12 +9,13 @@ export interface ControlsContextProps { handleSubmit: (e: React.FormEvent) => void; allowAttachments?: boolean; isLoading?: boolean; - } & Required + onValidate: (files: File[]) => Promise; + } & ConversationInputContextProps >; MessageList?: React.ComponentType<{ messages: ConversationMessage[] }>; PromptList?: React.ComponentType<{ suggestedPrompts?: SuggestedPrompt[]; - setInput: ConversationInputContext['setInput']; + setInput: ConversationInputContextProps['setInput']; }>; } diff --git a/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx b/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx index f6c2805ba50..dd56f6350b2 100644 --- a/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx @@ -5,15 +5,17 @@ export interface ConversationInput { files?: File[]; } -export interface ConversationInputContext { +export interface ConversationInputContextProps { input?: ConversationInput; setInput?: React.Dispatch< React.SetStateAction >; + error?: string; + setError?: React.Dispatch>; } export const ConversationInputContext = - React.createContext({}); + React.createContext({}); export const ConversationInputContextProvider = ({ children, @@ -21,10 +23,11 @@ export const ConversationInputContextProvider = ({ children?: React.ReactNode; }): JSX.Element => { const [input, setInput] = React.useState(); + const [error, setError] = React.useState(); const providerValue = React.useMemo( - () => ({ input, setInput }), - [input, setInput] + () => ({ input, setInput, error, setError }), + [input, setInput, error, setError] ); return ( diff --git a/packages/react-ai/src/components/AIConversation/context/index.ts b/packages/react-ai/src/components/AIConversation/context/index.ts index 05dc6506245..083cd7c4b39 100644 --- a/packages/react-ai/src/components/AIConversation/context/index.ts +++ b/packages/react-ai/src/components/AIConversation/context/index.ts @@ -2,6 +2,7 @@ export { AIContextContext, AIContextProvider } from './AIContextContext'; export { ActionsContext, ActionsProvider } from './ActionsContext'; export { AvatarsContext, AvatarsProvider } from './AvatarsContext'; export { + ConversationInputContextProps, ConversationInputContext, ConversationInput, ConversationInputContextProvider, @@ -40,7 +41,11 @@ export { MessageRendererContext, useMessageRenderer, } from './MessageRenderContext'; -export { AttachmentProvider, AttachmentContext } from './AttachmentContext'; +export { + AttachmentProvider, + AttachmentContext, + AttachmentContextProps, +} from './AttachmentContext'; export { WelcomeMessageContext, WelcomeMessageProvider, diff --git a/packages/react-ai/src/components/AIConversation/displayText.ts b/packages/react-ai/src/components/AIConversation/displayText.ts index 20737b5603a..3b2d055cddc 100644 --- a/packages/react-ai/src/components/AIConversation/displayText.ts +++ b/packages/react-ai/src/components/AIConversation/displayText.ts @@ -3,11 +3,21 @@ import { formatDate } from './utils'; export type ConversationDisplayText = { getMessageTimestampText?: (date: Date) => string; + getMaxAttachmentErrorText?: (count: number) => string; + getAttachmentSizeErrorText?: (sizeText: string) => string; }; export const defaultAIConversationDisplayTextEn: Required = { getMessageTimestampText: (date: Date) => formatDate(date), + getMaxAttachmentErrorText(count: number): string { + return `Cannot choose more than ${count} ${ + count === 1 ? 'file' : 'files' + }. `; + }, + getAttachmentSizeErrorText(sizeText: string): string { + return `File size must be below ${sizeText}.`; + }, }; export type AIConversationDisplayText = diff --git a/packages/react-ai/src/components/AIConversation/types.ts b/packages/react-ai/src/components/AIConversation/types.ts index f229c16e79e..69aef506b39 100644 --- a/packages/react-ai/src/components/AIConversation/types.ts +++ b/packages/react-ai/src/components/AIConversation/types.ts @@ -37,6 +37,8 @@ export interface AIConversationInput { variant?: MessageVariant; controls?: ControlsContextProps; allowAttachments?: boolean; + maxAttachments?: number; + maxAttachmentSize?: number; messageRenderer?: MessageRenderer; } diff --git a/packages/react-ai/src/components/AIConversation/utils.ts b/packages/react-ai/src/components/AIConversation/utils.ts index 023cec0be22..4eff9c9e6e5 100644 --- a/packages/react-ai/src/components/AIConversation/utils.ts +++ b/packages/react-ai/src/components/AIConversation/utils.ts @@ -16,27 +16,26 @@ export function formatDate(date: Date): string { } function arrayBufferToBase64(buffer: ArrayBuffer) { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); + // Use node-based buffer if available + // fall back on browser if not + if (typeof Buffer !== 'undefined') { + return Buffer.from(new Uint8Array(buffer)).toString('base64'); + } else { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); } - return window.btoa(binary); } export function convertBufferToBase64( buffer: ArrayBuffer, format: ImageContentBlock['format'] ): string { - let base64string = ''; - // Use node-based buffer if available - // fall back on browser if not - if (typeof Buffer !== 'undefined') { - base64string = Buffer.from(new Uint8Array(buffer)).toString('base64'); - } else { - base64string = arrayBufferToBase64(buffer); - } + const base64string = arrayBufferToBase64(buffer); return `data:image/${format};base64,${base64string}`; } @@ -45,3 +44,47 @@ export function getImageTypeFromMimeType( ): 'png' | 'jpeg' | 'gif' | 'webp' { return mimeType.split('/')[1] as 'png' | 'jpeg' | 'gif' | 'webp'; } + +export async function attachmentsValidator({ + files, + maxAttachments, + maxAttachmentSize, +}: { + files: File[]; + maxAttachments: number; + maxAttachmentSize: number; +}): Promise<{ + acceptedFiles: File[]; + rejectedFiles: File[]; + hasMaxAttachmentSizeError: boolean; + hasMaxAttachmentsError: boolean; +}> { + const acceptedFiles: File[] = []; + const rejectedFiles: File[] = []; + let hasMaxSizeError = false; + + for (const file of files) { + const arrayBuffer = await file.arrayBuffer(); + const base64 = arrayBufferToBase64(arrayBuffer); + if (base64.length < maxAttachmentSize) { + acceptedFiles.push(file); + } else { + rejectedFiles.push(file); + hasMaxSizeError = true; + } + } + if (acceptedFiles.length > maxAttachments) { + return { + acceptedFiles: acceptedFiles.slice(0, maxAttachments), + rejectedFiles: [...acceptedFiles.slice(maxAttachments), ...rejectedFiles], + hasMaxAttachmentsError: true, + hasMaxAttachmentSizeError: hasMaxSizeError, + }; + } + return { + acceptedFiles, + rejectedFiles, + hasMaxAttachmentsError: false, + hasMaxAttachmentSizeError: hasMaxSizeError, + }; +} diff --git a/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx b/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx index b0837fed2d4..18b8aad45f4 100644 --- a/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx +++ b/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { withBaseElementProps } from '@aws-amplify/ui-react-core/elements'; -import { AIContextContext, ConversationInputContext } from '../../context'; +import { + AIContextContext, + ConversationInputContext, + useConversationDisplayText, +} from '../../context'; import { AIConversationElements } from '../../context/elements'; import { AttachFileControl } from './AttachFileControl'; import { MessagesContext } from '../../context'; @@ -13,10 +17,10 @@ import { ResponseComponentsContext, } from '../../context/ResponseComponentsContext'; import { ControlsContext } from '../../context/ControlsContext'; -import { getImageTypeFromMimeType } from '../../utils'; +import { attachmentsValidator, getImageTypeFromMimeType } from '../../utils'; import { LoadingContext } from '../../context/LoadingContext'; import { AttachmentContext } from '../../context/AttachmentContext'; -import { isFunction } from '@aws-amplify/ui'; +import { humanFileSize, isFunction } from '@aws-amplify/ui'; const { Button, @@ -148,9 +152,13 @@ const InputContainer = withBaseElementProps(View, { }); export const FormControl: FormControl = () => { - const { input, setInput } = React.useContext(ConversationInputContext); + const { input, setInput, error, setError } = React.useContext( + ConversationInputContext + ); const handleSendMessage = React.useContext(SendMessageContext); - const allowAttachments = React.useContext(AttachmentContext); + const { allowAttachments, maxAttachmentSize, maxAttachments } = + React.useContext(AttachmentContext); + const displayText = useConversationDisplayText(); const responseComponents = React.useContext(ResponseComponentsContext); const isLoading = React.useContext(LoadingContext); const aiContext = React.useContext(AIContextContext); @@ -213,14 +221,57 @@ export const FormControl: FormControl = () => { } }; + const onValidate = React.useCallback( + async (files: File[]) => { + const previousFiles = input?.files ?? []; + const { + acceptedFiles, + hasMaxAttachmentsError, + hasMaxAttachmentSizeError, + } = await attachmentsValidator({ + files: [...files, ...previousFiles], + maxAttachments, + maxAttachmentSize, + }); + + if (hasMaxAttachmentsError || hasMaxAttachmentSizeError) { + const errors = []; + if (hasMaxAttachmentsError) { + errors.push(displayText.getMaxAttachmentErrorText(maxAttachments)); + } + if (hasMaxAttachmentSizeError) { + errors.push( + displayText.getAttachmentSizeErrorText( + // base64 size is about 137% that of the file size + // https://en.wikipedia.org/wiki/Base64#MIME + humanFileSize((maxAttachmentSize - 814) / 1.37, true) + ) + ); + } + setError?.(errors.join(' ')); + } else { + setError?.(undefined); + } + + setInput?.((prevValue) => ({ + ...prevValue, + files: acceptedFiles, + })); + }, + [setInput, input, displayText, maxAttachmentSize, maxAttachments, setError] + ); + if (controls?.Form) { return ( ); } diff --git a/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx b/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx index 0ad1798942a..764702cbd7e 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Button, Image, Text, View } from '@aws-amplify/ui-react'; import { IconClose, useIcons } from '@aws-amplify/ui-react/internal'; -import { ConversationInputContext } from '../../context'; +import { ConversationInputContextProps } from '../../context'; import { ComponentClassName, humanFileSize } from '@aws-amplify/ui'; const Attachment = ({ @@ -47,7 +47,7 @@ export const Attachments = ({ setInput, }: { files?: File[]; - setInput: ConversationInputContext['setInput']; + setInput: ConversationInputContextProps['setInput']; }): JSX.Element | null => { if (!files || files.length < 1) { return null; diff --git a/packages/react-ai/src/components/AIConversation/views/default/Form.tsx b/packages/react-ai/src/components/AIConversation/views/default/Form.tsx index e1fb0a8c39e..da32054596e 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/Form.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/Form.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Button, DropZone, + Message, TextAreaField, View, VisuallyHidden, @@ -10,7 +11,6 @@ import { IconAttach, IconSend, useIcons } from '@aws-amplify/ui-react/internal'; import { ComponentClassName } from '@aws-amplify/ui'; import { ControlsContextProps } from '../../context/ControlsContext'; import { Attachments } from './Attachments'; -import { ConversationInputContext } from '../../context'; function isHTMLFormElement(target: EventTarget): target is HTMLFormElement { return 'form' in target; @@ -23,21 +23,18 @@ function isHTMLFormElement(target: EventTarget): target is HTMLFormElement { const FormWrapper = ({ children, allowAttachments, - setInput, + onValidate, }: { children: React.ReactNode; allowAttachments?: boolean; - setInput: ConversationInputContext['setInput']; + onValidate: (files: File[]) => Promise; }) => { if (allowAttachments) { return ( { - setInput?.((prevInput) => ({ - ...prevInput, - files: [...(prevInput?.files ?? []), ...acceptedFiles], - })); + onValidate(acceptedFiles); }} > {children} @@ -53,7 +50,9 @@ export const Form: Required['Form'] = ({ input, handleSubmit, allowAttachments, + onValidate, isLoading, + error, }) => { const icons = useIcons('aiConversation'); const sendIcon = icons?.send ?? ; @@ -63,7 +62,7 @@ export const Form: Required['Form'] = ({ const isInputEmpty = !input?.text?.length && !input?.files?.length; return ( - + ['Form'] = ({ tabIndex={-1} ref={hiddenInput} onChange={(e) => { - const { files } = e.target; - if (!files || files.length === 0) { + if (!e.target.files || e.target.files.length === 0) { return; } - setInput((prevValue) => ({ - ...prevValue, - files: [...(prevValue?.files ?? []), ...Array.from(files)], - })); + onValidate(Array.from(e.target.files)); }} multiple - accept="*" + accept=".jpeg,.png,.webp,.gif" data-testid="hidden-file-input" /> @@ -123,7 +118,7 @@ export const Form: Required['Form'] = ({ } }} onChange={(e) => { - setInput((prevValue) => ({ + setInput?.((prevValue) => ({ ...prevValue, text: e.target.value, })); @@ -141,6 +136,15 @@ export const Form: Required['Form'] = ({ {sendIcon} + {error ? ( + + {error} + + ) : null} ); diff --git a/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx b/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx index ed166875a8c..121d2e23131 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx @@ -5,6 +5,15 @@ import { Form } from '../Form'; const setInput = jest.fn(); const input = {}; const handleSubmit = jest.fn(); +const onValidate = jest.fn(); + +const defaultProps = { + allowAttachments: true, + setInput, + input, + handleSubmit, + onValidate, +}; describe('Form', () => { beforeEach(() => { @@ -13,14 +22,7 @@ describe('Form', () => { }); it('renders a Form component with the correct elements', () => { - const result = render( -
- ); + const result = render(); expect(result.container).toBeDefined(); const form = screen.findByRole('form'); @@ -35,14 +37,7 @@ describe('Form', () => { }); it('can upload files to the input', async () => { - const result = render( - - ); + const result = render(); expect(result.container).toBeDefined(); const fileInput: HTMLInputElement = screen.getByTestId('hidden-file-input'); @@ -50,12 +45,15 @@ describe('Form', () => { type: 'text/plain', }); File.prototype.text = jest.fn().mockResolvedValueOnce('foo.txt'); + File.prototype.arrayBuffer = jest + .fn() + .mockResolvedValueOnce(Buffer.from([])); await waitFor(() => fireEvent.change(fileInput, { target: { files: [testFile] }, }) ); - expect(setInput).toHaveBeenCalledTimes(1); + expect(onValidate).toHaveBeenCalledTimes(1); expect(fileInput.files).not.toBeNull(); expect(fileInput.files![0]).toStrictEqual(testFile); }); diff --git a/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx b/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx index 70c2e80ff06..5a5910a6913 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { PromptList } from '../PromptList'; import { ComponentClassName } from '@aws-amplify/ui'; -import { ConversationInputContext } from '../../../context'; +import { ConversationInputContextProps } from '../../../context'; describe('PromptList', () => { const mockSetInput = jest.fn< - ReturnType['setInput']>, - Parameters['setInput']> + ReturnType['setInput']>, + Parameters['setInput']> >(); it('renders without crashing', () => { diff --git a/packages/ui/src/theme/components/aiConverstion.ts b/packages/ui/src/theme/components/aiConverstion.ts index 1bd747c910c..2de1ba95379 100644 --- a/packages/ui/src/theme/components/aiConverstion.ts +++ b/packages/ui/src/theme/components/aiConverstion.ts @@ -18,6 +18,7 @@ export type AIConversationTheme = form__dropzone?: ComponentStyles; form__attatch?: ComponentStyles; form__send?: ComponentStyles; + form_error?: ComponentStyles; form_field?: ComponentStyles; attachment?: ComponentStyles; diff --git a/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss b/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss index 5825133bf5f..a5946054eec 100644 --- a/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss +++ b/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss @@ -127,12 +127,19 @@ flex-direction: row; align-items: flex-start; gap: var(--amplify-components-ai-conversation-form-gap); - padding: var(--amplify-components-ai-conversation-form-padding); &__dropzone { text-align: initial; border: none; + padding: var(--amplify-components-ai-conversation-form-padding); + } + + &__error { padding: 0; + padding-block-start: var( + --amplify-components-ai-conversation-attachment-list-padding-block-start + ); + gap: var(--amplify-components-ai-conversation-attachment-gap); } &__attach { @@ -172,7 +179,7 @@ flex-wrap: wrap; gap: var(--amplify-components-ai-conversation-attachment-list-gap); padding-block-start: var( - --amplify-components-ai-conversation-attachment-padding-block-start + --amplify-components-ai-conversation-attachment-list-padding-block-start ); } diff --git a/packages/ui/src/types/primitives/componentClassName.ts b/packages/ui/src/types/primitives/componentClassName.ts index 0875fb0aa89..d14fdece61e 100644 --- a/packages/ui/src/types/primitives/componentClassName.ts +++ b/packages/ui/src/types/primitives/componentClassName.ts @@ -30,6 +30,7 @@ export const ComponentClassName = { AIConversationAttachmentRemove: 'amplify-ai-conversation__attachment__remove', AIConversationForm: 'amplify-ai-conversation__form', AIConversationFormAttach: 'amplify-ai-conversation__form__attach', + AIConversationFormError: 'amplify-ai-conversation__form__error', AIConversationFormSend: 'amplify-ai-conversation__form__send', AIConversationFormField: 'amplify-ai-conversation__form__field', AIConversationFormDropzone: 'amplify-ai-conversation__form__dropzone',