@aws-amplify/ui-react-ai 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/esm/components/AIConversation/displayText.mjs +3 -0
  2. package/dist/esm/components/AIConversation/utils.mjs +58 -7
  3. package/dist/esm/components/AIConversation/views/Controls/FormControl.mjs +49 -18
  4. package/dist/esm/components/AIConversation/views/Controls/MessagesControl.mjs +20 -3
  5. package/dist/esm/components/AIConversation/views/default/Attachments.mjs +6 -2
  6. package/dist/esm/components/AIConversation/views/default/Form.mjs +5 -6
  7. package/dist/esm/components/AIConversation/views/default/MessageList.mjs +1 -0
  8. package/dist/esm/hooks/useAIConversation.mjs +19 -15
  9. package/dist/esm/version.mjs +1 -1
  10. package/dist/index.js +154 -47
  11. package/dist/types/components/AIConversation/context/AIContextContext.d.ts +1 -1
  12. package/dist/types/components/AIConversation/context/ActionsContext.d.ts +1 -1
  13. package/dist/types/components/AIConversation/context/AttachmentContext.d.ts +1 -1
  14. package/dist/types/components/AIConversation/context/AvatarsContext.d.ts +1 -1
  15. package/dist/types/components/AIConversation/context/ControlsContext.d.ts +1 -1
  16. package/dist/types/components/AIConversation/context/ConversationInputContext.d.ts +1 -1
  17. package/dist/types/components/AIConversation/context/DisplayTextContext.d.ts +0 -1
  18. package/dist/types/components/AIConversation/context/FallbackComponentContext.d.ts +1 -1
  19. package/dist/types/components/AIConversation/context/LoadingContext.d.ts +1 -1
  20. package/dist/types/components/AIConversation/context/MessageRenderContext.d.ts +0 -1
  21. package/dist/types/components/AIConversation/context/MessageVariantContext.d.ts +1 -1
  22. package/dist/types/components/AIConversation/context/MessagesContext.d.ts +1 -1
  23. package/dist/types/components/AIConversation/context/ResponseComponentsContext.d.ts +1 -1
  24. package/dist/types/components/AIConversation/context/SendMessageContext.d.ts +1 -1
  25. package/dist/types/components/AIConversation/context/SuggestedPromptsContext.d.ts +1 -1
  26. package/dist/types/components/AIConversation/context/WelcomeMessageContext.d.ts +1 -1
  27. package/dist/types/components/AIConversation/context/elements/definitions.d.ts +0 -1
  28. package/dist/types/components/AIConversation/displayText.d.ts +1 -0
  29. package/dist/types/components/AIConversation/types.d.ts +4 -4
  30. package/dist/types/components/AIConversation/utils.d.ts +9 -2
  31. package/dist/types/components/AIConversation/views/Controls/AttachmentListControl.d.ts +5 -5
  32. package/dist/types/components/AIConversation/views/Controls/DefaultMessageControl.d.ts +2 -2
  33. package/dist/types/components/AIConversation/views/Controls/MessagesControl.d.ts +4 -3
  34. package/dist/types/components/AIConversation/views/default/Attachments.d.ts +2 -2
  35. package/dist/types/hooks/shared.d.ts +8 -0
  36. package/dist/types/hooks/useAIGeneration.d.ts +2 -2
  37. package/dist/types/types.d.ts +4 -4
  38. package/dist/types/version.d.ts +1 -1
  39. package/package.json +9 -13
@@ -8,6 +8,9 @@ const defaultAIConversationDisplayTextEn = {
8
8
  getAttachmentSizeErrorText(sizeText) {
9
9
  return `File size must be below ${sizeText}.`;
10
10
  },
11
+ getAttachmentFormatErrorText(formats) {
12
+ return `Files must be one of the supported types: ${formats.join(', ')}.`;
13
+ },
11
14
  };
12
15
 
13
16
  export { defaultAIConversationDisplayTextEn };
@@ -31,14 +31,63 @@ function convertBufferToBase64(buffer, format) {
31
31
  const base64string = arrayBufferToBase64(buffer);
32
32
  return `data:image/${format};base64,${base64string}`;
33
33
  }
34
- function getImageTypeFromMimeType(mimeType) {
35
- return mimeType.split('/')[1];
34
+ // This function will return the file extension or mime type
35
+ function getAttachmentFormat(file) {
36
+ const fileNameParts = file.name.split('.');
37
+ // try to get format from extension first
38
+ // this is because some document mime file types are very complex
39
+ // and don't easily map to what Bedrock needs like "doc" or "docx"
40
+ if (fileNameParts.length > 1) {
41
+ return fileNameParts[fileNameParts.length - 1];
42
+ }
43
+ // return mime type if no extension exists
44
+ return file.type.split('/')[1];
45
+ }
46
+ // This will take a File and return a string to be used as the document name
47
+ // in Bedrock. Bedrock has specific requirements around a valid name:
48
+ // https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html
49
+ function getValidDocumentName(file) {
50
+ const fileNameParts = file.name.split('.');
51
+ const baseFileName = fileNameParts.length > 1 ? fileNameParts.slice(0, -1).join('') : file.name;
52
+ // This is a regex to target special characters that are not allowed in file names
53
+ const specialCharactersRegex = /[!@#$%^&*()+\-=[\]{};':"\\|,.<>/?]/g;
54
+ return baseFileName.replace(specialCharactersRegex, '').replace(/\s+/g, '_');
55
+ }
56
+ // Using Sets instead of Arrays for faster and easier lookups
57
+ const documentFileTypes = new Set([
58
+ 'docx',
59
+ 'csv',
60
+ 'html',
61
+ 'txt',
62
+ 'pdf',
63
+ 'md',
64
+ 'doc',
65
+ 'xlsx',
66
+ 'xls',
67
+ ]);
68
+ const imageFileTypes = new Set(['png', 'jpeg', 'gif', 'webp']);
69
+ const validFileTypes = new Set([
70
+ ...documentFileTypes,
71
+ ...imageFileTypes,
72
+ ]);
73
+ function isDocumentFormat(format) {
74
+ return documentFileTypes.has(format);
75
+ }
76
+ function isImageFormat(format) {
77
+ return imageFileTypes.has(format);
36
78
  }
37
79
  async function attachmentsValidator({ files, maxAttachments, maxAttachmentSize, }) {
38
80
  const acceptedFiles = [];
39
81
  const rejectedFiles = [];
40
- let hasMaxSizeError = false;
82
+ let hasMaxAttachmentSizeError = false;
83
+ let hasUnsupportedFileError = false;
41
84
  for (const file of files) {
85
+ const format = getAttachmentFormat(file);
86
+ if (!validFileTypes.has(format)) {
87
+ rejectedFiles.push(file);
88
+ hasUnsupportedFileError = true;
89
+ continue;
90
+ }
42
91
  const arrayBuffer = await file.arrayBuffer();
43
92
  const base64 = arrayBufferToBase64(arrayBuffer);
44
93
  if (base64.length < maxAttachmentSize) {
@@ -46,23 +95,25 @@ async function attachmentsValidator({ files, maxAttachments, maxAttachmentSize,
46
95
  }
47
96
  else {
48
97
  rejectedFiles.push(file);
49
- hasMaxSizeError = true;
98
+ hasMaxAttachmentSizeError = true;
50
99
  }
51
100
  }
52
101
  if (acceptedFiles.length > maxAttachments) {
53
102
  return {
54
103
  acceptedFiles: acceptedFiles.slice(0, maxAttachments),
55
104
  rejectedFiles: [...acceptedFiles.slice(maxAttachments), ...rejectedFiles],
105
+ hasMaxAttachmentSizeError,
106
+ hasUnsupportedFileError,
56
107
  hasMaxAttachmentsError: true,
57
- hasMaxAttachmentSizeError: hasMaxSizeError,
58
108
  };
59
109
  }
60
110
  return {
61
111
  acceptedFiles,
62
112
  rejectedFiles,
113
+ hasMaxAttachmentSizeError,
114
+ hasUnsupportedFileError,
63
115
  hasMaxAttachmentsError: false,
64
- hasMaxAttachmentSizeError: hasMaxSizeError,
65
116
  };
66
117
  }
67
118
 
68
- export { attachmentsValidator, convertBufferToBase64, formatDate, getImageTypeFromMimeType };
119
+ export { attachmentsValidator, convertBufferToBase64, documentFileTypes, formatDate, getAttachmentFormat, getValidDocumentName, imageFileTypes, isDocumentFormat, isImageFormat, validFileTypes };
@@ -19,7 +19,7 @@ import '../../context/FallbackComponentContext.mjs';
19
19
  import { AIConversationElements } from '../../context/elements/definitions.mjs';
20
20
  import { AttachFileControl } from './AttachFileControl.mjs';
21
21
  import { AttachmentListControl } from './AttachmentListControl.mjs';
22
- import { attachmentsValidator, getImageTypeFromMimeType } from '../../utils.mjs';
22
+ import { attachmentsValidator, validFileTypes, getAttachmentFormat, isDocumentFormat, getValidDocumentName, isImageFormat } from '../../utils.mjs';
23
23
  import { humanFileSize, isFunction } from '@aws-amplify/ui';
24
24
 
25
25
  const { Button, Icon, Label: LabelElement, TextArea, View, } = AIConversationElements;
@@ -102,6 +102,8 @@ const TextInput = React__default.forwardRef(function TextInput(props, ref) {
102
102
  const InputContainer = withBaseElementProps(View, {
103
103
  className: `${FIELD_BLOCK}__input-container`,
104
104
  });
105
+ const isConversationInputWithText = (input) => !!input?.text;
106
+ const isConversationInputWithFiles = (input) => !!input?.files?.length;
105
107
  const FormControl = () => {
106
108
  const { input, setInput, error, setError } = React__default.useContext(ConversationInputContext);
107
109
  const handleSendMessage = React__default.useContext(SendMessageContext);
@@ -113,25 +115,46 @@ const FormControl = () => {
113
115
  const ref = React__default.useRef(null);
114
116
  const controls = React__default.useContext(ControlsContext);
115
117
  const [composing, setComposing] = React__default.useState(false);
118
+ const [isSubmitting, setIsSubmitting] = React__default.useState(false);
119
+ const isInputText = isConversationInputWithText(input);
120
+ // an empty array will resolve false when evaluating the length
121
+ const isInputFiles = isConversationInputWithFiles(input);
116
122
  const submitMessage = async () => {
117
- ref.current?.reset();
123
+ const hasInput = isInputFiles || isInputText;
124
+ // Prevent double submission and empty submission
125
+ if (isSubmitting || !hasInput) {
126
+ return;
127
+ }
128
+ setIsSubmitting(true);
118
129
  const submittedContent = [];
119
- if (input?.text) {
130
+ if (isInputText) {
120
131
  const textContent = {
121
132
  text: input.text,
122
133
  };
123
134
  submittedContent.push(textContent);
124
135
  }
125
- if (input?.files) {
136
+ if (isInputFiles) {
126
137
  for (const file of input.files) {
127
138
  const buffer = await file.arrayBuffer();
128
- const fileContent = {
129
- image: {
130
- format: getImageTypeFromMimeType(file.type),
131
- source: { bytes: new Uint8Array(buffer) },
132
- },
133
- };
134
- submittedContent.push(fileContent);
139
+ const format = getAttachmentFormat(file);
140
+ const source = { bytes: new Uint8Array(buffer) };
141
+ if (isDocumentFormat(format)) {
142
+ submittedContent.push({
143
+ document: {
144
+ name: getValidDocumentName(file),
145
+ format,
146
+ source,
147
+ },
148
+ });
149
+ }
150
+ else if (isImageFormat(format)) {
151
+ submittedContent.push({
152
+ image: {
153
+ format,
154
+ source,
155
+ },
156
+ });
157
+ }
135
158
  }
136
159
  }
137
160
  if (handleSendMessage) {
@@ -141,6 +164,12 @@ const FormControl = () => {
141
164
  toolConfiguration: convertResponseComponentsToToolConfiguration(responseComponents),
142
165
  });
143
166
  }
167
+ // Clear the attachment errors when submitting
168
+ // because the errors are not actually preventing the submission
169
+ // but rather notifying the user that certain files were not attached and why they weren't
170
+ setError?.(undefined);
171
+ setIsSubmitting(false);
172
+ ref.current?.reset();
144
173
  if (setInput)
145
174
  setInput({ text: '', files: [] });
146
175
  };
@@ -152,24 +181,26 @@ const FormControl = () => {
152
181
  const { key, shiftKey } = event;
153
182
  if (key === 'Enter' && !shiftKey && !composing) {
154
183
  event.preventDefault();
155
- const hasInput = !!input?.text || (input?.files?.length && input?.files?.length > 0);
156
- if (hasInput) {
157
- submitMessage();
158
- }
184
+ submitMessage();
159
185
  }
160
186
  };
161
187
  const onValidate = React__default.useCallback(async (files) => {
162
188
  const previousFiles = input?.files ?? [];
163
- const { acceptedFiles, hasMaxAttachmentsError, hasMaxAttachmentSizeError, } = await attachmentsValidator({
189
+ const { acceptedFiles, hasMaxAttachmentsError, hasMaxAttachmentSizeError, hasUnsupportedFileError, } = await attachmentsValidator({
164
190
  files: [...files, ...previousFiles],
165
191
  maxAttachments,
166
192
  maxAttachmentSize,
167
193
  });
168
- if (hasMaxAttachmentsError || hasMaxAttachmentSizeError) {
194
+ if (hasMaxAttachmentsError ||
195
+ hasMaxAttachmentSizeError ||
196
+ hasUnsupportedFileError) {
169
197
  const errors = [];
170
198
  if (hasMaxAttachmentsError) {
171
199
  errors.push(displayText.getMaxAttachmentErrorText(maxAttachments));
172
200
  }
201
+ if (hasUnsupportedFileError) {
202
+ errors.push(displayText.getAttachmentFormatErrorText([...validFileTypes]));
203
+ }
173
204
  if (hasMaxAttachmentSizeError) {
174
205
  errors.push(displayText.getAttachmentSizeErrorText(
175
206
  // base64 size is about 137% that of the file size
@@ -187,7 +218,7 @@ const FormControl = () => {
187
218
  }));
188
219
  }, [setInput, input, displayText, maxAttachmentSize, maxAttachments, setError]);
189
220
  if (controls?.Form) {
190
- return (React__default.createElement(controls.Form, { handleSubmit: handleSubmit, input: input, setInput: setInput, onValidate: onValidate, allowAttachments: allowAttachments, isLoading: isLoading, error: error, setError: setError }));
221
+ return (React__default.createElement(controls.Form, { handleSubmit: handleSubmit, input: input, setInput: setInput, onValidate: onValidate, allowAttachments: allowAttachments, isLoading: isLoading ?? isSubmitting, error: error, setError: setError }));
191
222
  }
192
223
  return (React__default.createElement("form", { className: `${FIELD_BLOCK}__form`, onSubmit: handleSubmit, method: "post", ref: ref },
193
224
  allowAttachments ? React__default.createElement(AttachFileControl, null) : null,
@@ -1,5 +1,7 @@
1
1
  import React__default from 'react';
2
+ import { classNames, ComponentClassName, humanFileSize } from '@aws-amplify/ui';
2
3
  import { Image } from '@aws-amplify/ui-react';
4
+ import { useIcons, IconDocument } from '@aws-amplify/ui-react/internal';
3
5
  import { withBaseElementProps } from '@aws-amplify/ui-react-core/elements';
4
6
  import '../../context/AIContextContext.mjs';
5
7
  import '../../context/ActionsContext.mjs';
@@ -21,7 +23,6 @@ import { AIConversationElements } from '../../context/elements/definitions.mjs';
21
23
  import { convertBufferToBase64 } from '../../utils.mjs';
22
24
  import { ActionsBarControl } from './ActionsBarControl.mjs';
23
25
  import { AvatarControl } from './AvatarControl.mjs';
24
- import { classNames } from '@aws-amplify/ui';
25
26
 
26
27
  const { Text, View } = AIConversationElements;
27
28
  const MESSAGES_BLOCK = 'amplify-ai-conversation__message__list';
@@ -55,6 +56,17 @@ const ToolContent = ({ toolUse, }) => {
55
56
  }
56
57
  }
57
58
  };
59
+ const DocumentContent = ({ format, name, source, }) => {
60
+ const icons = useIcons('aiConversation');
61
+ const fileIcon = icons?.document ?? React__default.createElement(IconDocument, null);
62
+ return (React__default.createElement(View, { className: ComponentClassName.AIConversationAttachment },
63
+ fileIcon,
64
+ React__default.createElement(View, { className: ComponentClassName.AIConversationAttachmentName },
65
+ name,
66
+ ".",
67
+ format),
68
+ React__default.createElement(View, { className: ComponentClassName.AIConversationAttachmentSize }, humanFileSize(source.bytes.length, true))));
69
+ };
58
70
  const MessageControl = ({ message }) => {
59
71
  const messageRenderer = React__default.useContext(MessageRendererContext);
60
72
  return (React__default.createElement(React__default.Fragment, null, message.content.map((content, index) => {
@@ -64,6 +76,9 @@ const MessageControl = ({ message }) => {
64
76
  else if (content.image) {
65
77
  return messageRenderer?.image ? (React__default.createElement(React__default.Fragment, { key: index }, messageRenderer?.image({ image: content.image }))) : (React__default.createElement(MediaContent, { "data-testid": 'image-content', key: index, alt: "", src: convertBufferToBase64(content.image?.source.bytes, content.image?.format) }));
66
78
  }
79
+ else if (content.document) {
80
+ return React__default.createElement(DocumentContent, { key: index, ...content.document });
81
+ }
67
82
  else if (content.toolUse) {
68
83
  return React__default.createElement(ToolContent, { toolUse: content.toolUse, key: index });
69
84
  }
@@ -126,7 +141,9 @@ const MessagesControl = () => {
126
141
  content.toolUse?.name.startsWith(RESPONSE_COMPONENT_PREFIX))) ?? [];
127
142
  return (React__default.createElement(Layout, null, messagesWithRenderableContent?.map((message, index) => {
128
143
  return (React__default.createElement(RoleContext.Provider, { value: message.role, key: `message-${index}` },
129
- React__default.createElement(MessageContainer, { "data-testid": `message`, key: `message-${index}`, tabIndex: focusedItemIndex === index ? 0 : -1, onFocus: () => handleFocus(index), onKeyDown: (event) => onKeyDown(index, event), ref: (el) => (messagesRef.current[index] = el) },
144
+ React__default.createElement(MessageContainer, { "data-testid": `message`, key: `message-${index}`, tabIndex: focusedItemIndex === index ? 0 : -1, onFocus: () => handleFocus(index), onKeyDown: (event) => onKeyDown(index, event), ref: (el) => {
145
+ messagesRef.current[index] = el;
146
+ } },
130
147
  React__default.createElement(HeaderContainer, null,
131
148
  React__default.createElement(AvatarControl, null),
132
149
  React__default.createElement(Timestamp, null, getMessageTimestampText(new Date(message.createdAt)))),
@@ -141,4 +158,4 @@ MessagesControl.HeaderContainer = HeaderContainer;
141
158
  MessagesControl.Layout = Layout;
142
159
  MessagesControl.Message = MessageControl;
143
160
 
144
- export { MessageControl, MessagesControl };
161
+ export { DocumentContent, MessageControl, MessagesControl };
@@ -1,13 +1,17 @@
1
1
  import * as React from 'react';
2
2
  import { View, Image, Text, Button } from '@aws-amplify/ui-react';
3
- import { useIcons, IconClose } from '@aws-amplify/ui-react/internal';
3
+ import { useIcons, IconClose, IconDocument } from '@aws-amplify/ui-react/internal';
4
4
  import { ComponentClassName, humanFileSize } from '@aws-amplify/ui';
5
+ import { getAttachmentFormat, documentFileTypes } from '../../utils.mjs';
5
6
 
6
7
  const Attachment = ({ file, handleRemove, }) => {
7
8
  const icons = useIcons('aiConversation');
8
9
  const removeIcon = icons?.remove ?? React.createElement(IconClose, null);
10
+ const fileIcon = icons?.document ?? React.createElement(IconDocument, null);
11
+ const format = getAttachmentFormat(file);
12
+ const isDocument = documentFileTypes.has(format);
9
13
  return (React.createElement(View, { className: ComponentClassName.AIConversationAttachment },
10
- React.createElement(Image, { className: ComponentClassName.AIConversationAttachmentImage, src: URL.createObjectURL(file), alt: file.name }),
14
+ isDocument ? (fileIcon) : (React.createElement(Image, { className: ComponentClassName.AIConversationAttachmentImage, src: URL.createObjectURL(file), alt: file.name })),
11
15
  React.createElement(Text, { as: "span", className: ComponentClassName.AIConversationAttachmentName }, file.name),
12
16
  React.createElement(Text, { as: "span", className: ComponentClassName.AIConversationAttachmentSize }, humanFileSize(file.size, true)),
13
17
  React.createElement(Button, { size: "small", variation: "link", colorTheme: "error", className: ComponentClassName.AIConversationAttachmentRemove, onClick: handleRemove }, removeIcon)));
@@ -3,6 +3,7 @@ import { View, Button, VisuallyHidden, TextAreaField, Message, DropZone } from '
3
3
  import { useIcons, IconSend, IconAttach } from '@aws-amplify/ui-react/internal';
4
4
  import { ComponentClassName } from '@aws-amplify/ui';
5
5
  import { Attachments } from './Attachments.mjs';
6
+ import { validFileTypes } from '../../utils.mjs';
6
7
 
7
8
  function isHTMLFormElement(target) {
8
9
  return 'form' in target;
@@ -26,8 +27,9 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
26
27
  const sendIcon = icons?.send ?? React.createElement(IconSend, null);
27
28
  const attachIcon = icons?.attach ?? React.createElement(IconAttach, null);
28
29
  const hiddenInput = React.useRef(null);
30
+ // Bedrock does not accept message that are empty or are only whitespace
31
+ const isInputEmpty = !input?.text?.length || !!input.text.match(/^\s+$/);
29
32
  const [composing, setComposing] = React.useState(false);
30
- const isInputEmpty = !input?.text?.length && !input?.files?.length;
31
33
  return (React.createElement(FormWrapper, { onValidate: onValidate, allowAttachments: allowAttachments },
32
34
  React.createElement(View, { as: "form", className: ComponentClassName.AIConversationForm, onSubmit: handleSubmit },
33
35
  allowAttachments ? (React.createElement(Button, { className: ComponentClassName.AIConversationFormAttach, onClick: () => {
@@ -43,7 +45,7 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
43
45
  return;
44
46
  }
45
47
  onValidate(Array.from(e.target.files));
46
- }, multiple: true, accept: ".jpeg,.png,.webp,.gif", "data-testid": "hidden-file-input" })))) : null,
48
+ }, multiple: true, accept: [...validFileTypes].map((type) => `.${type}`).join(','), "data-testid": "hidden-file-input" })))) : null,
47
49
  React.createElement(TextAreaField, { className: ComponentClassName.AIConversationFormField, label: "input", labelHidden: true, autoResize: true, flex: "1", rows: 1, value: input?.text ?? '', testId: "text-input", onCompositionStart: () => setComposing(true), onCompositionEnd: () => setComposing(false), onKeyDown: (e) => {
48
50
  // Submit on enter key if shift is not pressed also
49
51
  const shouldSubmit = !e.shiftKey && e.key === 'Enter' && !composing;
@@ -57,10 +59,7 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
57
59
  text: e.target.value,
58
60
  }));
59
61
  } }),
60
- React.createElement(Button, { type: "submit", variation: "primary", className: ComponentClassName.AIConversationFormSend,
61
- // we intentionally || in the case where isLoading is false we should use the value of isInputEmpty
62
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
63
- isDisabled: isLoading || isInputEmpty },
62
+ React.createElement(Button, { type: "submit", variation: "primary", className: ComponentClassName.AIConversationFormSend, isDisabled: isLoading ?? isInputEmpty },
64
63
  React.createElement("span", null, sendIcon))),
65
64
  error ? (React.createElement(Message, { className: ComponentClassName.AIConversationFormError, variation: "plain", colorTheme: "warning" }, error)) : null,
66
65
  React.createElement(Attachments, { setInput: setInput, files: input?.files })));
@@ -70,6 +70,7 @@ const MessageList = ({ messages, }) => {
70
70
  const isLoading = React.useContext(LoadingContext);
71
71
  const messagesWithRenderableContent = messages?.filter((message) => message.content.some((content) => content.image ??
72
72
  content.text ??
73
+ content.document ??
73
74
  content.toolUse?.name.startsWith(RESPONSE_COMPONENT_PREFIX))) ?? [];
74
75
  return (React.createElement(View, { className: ComponentClassName.AIConversationMessageList },
75
76
  isLoading ? (React.createElement(React.Fragment, null,
@@ -22,7 +22,7 @@ function createUseAIConversation(client) {
22
22
  const clientRoute = client.conversations[routeName];
23
23
  // We need to keep track of the stream events as the come in
24
24
  // for an assistant message, but don't need to keep them in state
25
- const contentBlocksRef = React__default.useRef();
25
+ const contentBlocksRef = React__default.useRef(undefined);
26
26
  // Using this hook without an existing conversation id means
27
27
  // it will create a new conversation when it is executed
28
28
  // we don't want to create 2 conversations
@@ -82,16 +82,17 @@ function createUseAIConversation(client) {
82
82
  // between the gen2 schema definition and
83
83
  // whats in amplify_outputs
84
84
  if (!clientRoute) {
85
+ const error = {
86
+ message: 'Conversation route does not exist',
87
+ errorInfo: null,
88
+ errorType: '',
89
+ };
85
90
  setDataState({
86
91
  ...ERROR_STATE,
87
92
  data: { messages: [] },
88
- messages: [
89
- {
90
- message: 'Conversation route does not exist',
91
- errorInfo: null,
92
- errorType: '',
93
- },
94
- ],
93
+ // TODO in MV bump: remove `messages`
94
+ messages: [error],
95
+ errors: [error],
95
96
  });
96
97
  return;
97
98
  }
@@ -205,7 +206,9 @@ function createUseAIConversation(client) {
205
206
  return {
206
207
  ...prev,
207
208
  ...ERROR_STATE,
209
+ // TODO in MV bump: remove `messages`
208
210
  messages: error.errors,
211
+ errors: error.errors,
209
212
  };
210
213
  });
211
214
  },
@@ -249,16 +252,17 @@ function createUseAIConversation(client) {
249
252
  conversation.sendMessage(input);
250
253
  }
251
254
  else {
255
+ const error = {
256
+ message: 'No conversation found',
257
+ errorInfo: null,
258
+ errorType: '',
259
+ };
252
260
  setDataState((prev) => ({
253
261
  ...prev,
254
262
  ...ERROR_STATE,
255
- messages: [
256
- {
257
- message: 'No conversation found',
258
- errorInfo: null,
259
- errorType: '',
260
- },
261
- ],
263
+ // TODO in MV bump: remove `messages`
264
+ messages: [error],
265
+ errors: [error],
262
266
  }));
263
267
  }
264
268
  }, [conversation]);
@@ -1,3 +1,3 @@
1
- const VERSION = '1.2.1';
1
+ const VERSION = '1.4.0';
2
2
 
3
3
  export { VERSION };
package/dist/index.js CHANGED
@@ -102,14 +102,63 @@ function convertBufferToBase64(buffer, format) {
102
102
  const base64string = arrayBufferToBase64(buffer);
103
103
  return `data:image/${format};base64,${base64string}`;
104
104
  }
105
- function getImageTypeFromMimeType(mimeType) {
106
- return mimeType.split('/')[1];
105
+ // This function will return the file extension or mime type
106
+ function getAttachmentFormat(file) {
107
+ const fileNameParts = file.name.split('.');
108
+ // try to get format from extension first
109
+ // this is because some document mime file types are very complex
110
+ // and don't easily map to what Bedrock needs like "doc" or "docx"
111
+ if (fileNameParts.length > 1) {
112
+ return fileNameParts[fileNameParts.length - 1];
113
+ }
114
+ // return mime type if no extension exists
115
+ return file.type.split('/')[1];
116
+ }
117
+ // This will take a File and return a string to be used as the document name
118
+ // in Bedrock. Bedrock has specific requirements around a valid name:
119
+ // https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html
120
+ function getValidDocumentName(file) {
121
+ const fileNameParts = file.name.split('.');
122
+ const baseFileName = fileNameParts.length > 1 ? fileNameParts.slice(0, -1).join('') : file.name;
123
+ // This is a regex to target special characters that are not allowed in file names
124
+ const specialCharactersRegex = /[!@#$%^&*()+\-=[\]{};':"\\|,.<>/?]/g;
125
+ return baseFileName.replace(specialCharactersRegex, '').replace(/\s+/g, '_');
126
+ }
127
+ // Using Sets instead of Arrays for faster and easier lookups
128
+ const documentFileTypes = new Set([
129
+ 'docx',
130
+ 'csv',
131
+ 'html',
132
+ 'txt',
133
+ 'pdf',
134
+ 'md',
135
+ 'doc',
136
+ 'xlsx',
137
+ 'xls',
138
+ ]);
139
+ const imageFileTypes = new Set(['png', 'jpeg', 'gif', 'webp']);
140
+ const validFileTypes = new Set([
141
+ ...documentFileTypes,
142
+ ...imageFileTypes,
143
+ ]);
144
+ function isDocumentFormat(format) {
145
+ return documentFileTypes.has(format);
146
+ }
147
+ function isImageFormat(format) {
148
+ return imageFileTypes.has(format);
107
149
  }
108
150
  async function attachmentsValidator({ files, maxAttachments, maxAttachmentSize, }) {
109
151
  const acceptedFiles = [];
110
152
  const rejectedFiles = [];
111
- let hasMaxSizeError = false;
153
+ let hasMaxAttachmentSizeError = false;
154
+ let hasUnsupportedFileError = false;
112
155
  for (const file of files) {
156
+ const format = getAttachmentFormat(file);
157
+ if (!validFileTypes.has(format)) {
158
+ rejectedFiles.push(file);
159
+ hasUnsupportedFileError = true;
160
+ continue;
161
+ }
113
162
  const arrayBuffer = await file.arrayBuffer();
114
163
  const base64 = arrayBufferToBase64(arrayBuffer);
115
164
  if (base64.length < maxAttachmentSize) {
@@ -117,22 +166,24 @@ async function attachmentsValidator({ files, maxAttachments, maxAttachmentSize,
117
166
  }
118
167
  else {
119
168
  rejectedFiles.push(file);
120
- hasMaxSizeError = true;
169
+ hasMaxAttachmentSizeError = true;
121
170
  }
122
171
  }
123
172
  if (acceptedFiles.length > maxAttachments) {
124
173
  return {
125
174
  acceptedFiles: acceptedFiles.slice(0, maxAttachments),
126
175
  rejectedFiles: [...acceptedFiles.slice(maxAttachments), ...rejectedFiles],
176
+ hasMaxAttachmentSizeError,
177
+ hasUnsupportedFileError,
127
178
  hasMaxAttachmentsError: true,
128
- hasMaxAttachmentSizeError: hasMaxSizeError,
129
179
  };
130
180
  }
131
181
  return {
132
182
  acceptedFiles,
133
183
  rejectedFiles,
184
+ hasMaxAttachmentSizeError,
185
+ hasUnsupportedFileError,
134
186
  hasMaxAttachmentsError: false,
135
- hasMaxAttachmentSizeError: hasMaxSizeError,
136
187
  };
137
188
  }
138
189
 
@@ -144,6 +195,9 @@ const defaultAIConversationDisplayTextEn = {
144
195
  getAttachmentSizeErrorText(sizeText) {
145
196
  return `File size must be below ${sizeText}.`;
146
197
  },
198
+ getAttachmentFormatErrorText(formats) {
199
+ return `Files must be one of the supported types: ${formats.join(', ')}.`;
200
+ },
147
201
  };
148
202
 
149
203
  const { ConversationDisplayTextContext, ConversationDisplayTextProvider, useConversationDisplayText, } = uiReactCore.createContextUtilities({
@@ -605,6 +659,8 @@ const TextInput = React__namespace["default"].forwardRef(function TextInput(prop
605
659
  const InputContainer = elements.withBaseElementProps(View$2, {
606
660
  className: `${FIELD_BLOCK}__input-container`,
607
661
  });
662
+ const isConversationInputWithText = (input) => !!input?.text;
663
+ const isConversationInputWithFiles = (input) => !!input?.files?.length;
608
664
  const FormControl = () => {
609
665
  const { input, setInput, error, setError } = React__namespace["default"].useContext(ConversationInputContext);
610
666
  const handleSendMessage = React__namespace["default"].useContext(SendMessageContext);
@@ -616,25 +672,46 @@ const FormControl = () => {
616
672
  const ref = React__namespace["default"].useRef(null);
617
673
  const controls = React__namespace["default"].useContext(ControlsContext);
618
674
  const [composing, setComposing] = React__namespace["default"].useState(false);
675
+ const [isSubmitting, setIsSubmitting] = React__namespace["default"].useState(false);
676
+ const isInputText = isConversationInputWithText(input);
677
+ // an empty array will resolve false when evaluating the length
678
+ const isInputFiles = isConversationInputWithFiles(input);
619
679
  const submitMessage = async () => {
620
- ref.current?.reset();
680
+ const hasInput = isInputFiles || isInputText;
681
+ // Prevent double submission and empty submission
682
+ if (isSubmitting || !hasInput) {
683
+ return;
684
+ }
685
+ setIsSubmitting(true);
621
686
  const submittedContent = [];
622
- if (input?.text) {
687
+ if (isInputText) {
623
688
  const textContent = {
624
689
  text: input.text,
625
690
  };
626
691
  submittedContent.push(textContent);
627
692
  }
628
- if (input?.files) {
693
+ if (isInputFiles) {
629
694
  for (const file of input.files) {
630
695
  const buffer = await file.arrayBuffer();
631
- const fileContent = {
632
- image: {
633
- format: getImageTypeFromMimeType(file.type),
634
- source: { bytes: new Uint8Array(buffer) },
635
- },
636
- };
637
- submittedContent.push(fileContent);
696
+ const format = getAttachmentFormat(file);
697
+ const source = { bytes: new Uint8Array(buffer) };
698
+ if (isDocumentFormat(format)) {
699
+ submittedContent.push({
700
+ document: {
701
+ name: getValidDocumentName(file),
702
+ format,
703
+ source,
704
+ },
705
+ });
706
+ }
707
+ else if (isImageFormat(format)) {
708
+ submittedContent.push({
709
+ image: {
710
+ format,
711
+ source,
712
+ },
713
+ });
714
+ }
638
715
  }
639
716
  }
640
717
  if (handleSendMessage) {
@@ -644,6 +721,12 @@ const FormControl = () => {
644
721
  toolConfiguration: convertResponseComponentsToToolConfiguration(responseComponents),
645
722
  });
646
723
  }
724
+ // Clear the attachment errors when submitting
725
+ // because the errors are not actually preventing the submission
726
+ // but rather notifying the user that certain files were not attached and why they weren't
727
+ setError?.(undefined);
728
+ setIsSubmitting(false);
729
+ ref.current?.reset();
647
730
  if (setInput)
648
731
  setInput({ text: '', files: [] });
649
732
  };
@@ -655,24 +738,26 @@ const FormControl = () => {
655
738
  const { key, shiftKey } = event;
656
739
  if (key === 'Enter' && !shiftKey && !composing) {
657
740
  event.preventDefault();
658
- const hasInput = !!input?.text || (input?.files?.length && input?.files?.length > 0);
659
- if (hasInput) {
660
- submitMessage();
661
- }
741
+ submitMessage();
662
742
  }
663
743
  };
664
744
  const onValidate = React__namespace["default"].useCallback(async (files) => {
665
745
  const previousFiles = input?.files ?? [];
666
- const { acceptedFiles, hasMaxAttachmentsError, hasMaxAttachmentSizeError, } = await attachmentsValidator({
746
+ const { acceptedFiles, hasMaxAttachmentsError, hasMaxAttachmentSizeError, hasUnsupportedFileError, } = await attachmentsValidator({
667
747
  files: [...files, ...previousFiles],
668
748
  maxAttachments,
669
749
  maxAttachmentSize,
670
750
  });
671
- if (hasMaxAttachmentsError || hasMaxAttachmentSizeError) {
751
+ if (hasMaxAttachmentsError ||
752
+ hasMaxAttachmentSizeError ||
753
+ hasUnsupportedFileError) {
672
754
  const errors = [];
673
755
  if (hasMaxAttachmentsError) {
674
756
  errors.push(displayText.getMaxAttachmentErrorText(maxAttachments));
675
757
  }
758
+ if (hasUnsupportedFileError) {
759
+ errors.push(displayText.getAttachmentFormatErrorText([...validFileTypes]));
760
+ }
676
761
  if (hasMaxAttachmentSizeError) {
677
762
  errors.push(displayText.getAttachmentSizeErrorText(
678
763
  // base64 size is about 137% that of the file size
@@ -690,7 +775,7 @@ const FormControl = () => {
690
775
  }));
691
776
  }, [setInput, input, displayText, maxAttachmentSize, maxAttachments, setError]);
692
777
  if (controls?.Form) {
693
- return (React__namespace["default"].createElement(controls.Form, { handleSubmit: handleSubmit, input: input, setInput: setInput, onValidate: onValidate, allowAttachments: allowAttachments, isLoading: isLoading, error: error, setError: setError }));
778
+ return (React__namespace["default"].createElement(controls.Form, { handleSubmit: handleSubmit, input: input, setInput: setInput, onValidate: onValidate, allowAttachments: allowAttachments, isLoading: isLoading ?? isSubmitting, error: error, setError: setError }));
694
779
  }
695
780
  return (React__namespace["default"].createElement("form", { className: `${FIELD_BLOCK}__form`, onSubmit: handleSubmit, method: "post", ref: ref },
696
781
  allowAttachments ? React__namespace["default"].createElement(AttachFileControl, null) : null,
@@ -741,6 +826,17 @@ const ToolContent = ({ toolUse, }) => {
741
826
  }
742
827
  }
743
828
  };
829
+ const DocumentContent = ({ format, name, source, }) => {
830
+ const icons = internal.useIcons('aiConversation');
831
+ const fileIcon = icons?.document ?? React__namespace["default"].createElement(internal.IconDocument, null);
832
+ return (React__namespace["default"].createElement(View$1, { className: ui.ComponentClassName.AIConversationAttachment },
833
+ fileIcon,
834
+ React__namespace["default"].createElement(View$1, { className: ui.ComponentClassName.AIConversationAttachmentName },
835
+ name,
836
+ ".",
837
+ format),
838
+ React__namespace["default"].createElement(View$1, { className: ui.ComponentClassName.AIConversationAttachmentSize }, ui.humanFileSize(source.bytes.length, true))));
839
+ };
744
840
  const MessageControl = ({ message }) => {
745
841
  const messageRenderer = React__namespace["default"].useContext(MessageRendererContext);
746
842
  return (React__namespace["default"].createElement(React__namespace["default"].Fragment, null, message.content.map((content, index) => {
@@ -750,6 +846,9 @@ const MessageControl = ({ message }) => {
750
846
  else if (content.image) {
751
847
  return messageRenderer?.image ? (React__namespace["default"].createElement(React__namespace["default"].Fragment, { key: index }, messageRenderer?.image({ image: content.image }))) : (React__namespace["default"].createElement(MediaContent, { "data-testid": 'image-content', key: index, alt: "", src: convertBufferToBase64(content.image?.source.bytes, content.image?.format) }));
752
848
  }
849
+ else if (content.document) {
850
+ return React__namespace["default"].createElement(DocumentContent, { key: index, ...content.document });
851
+ }
753
852
  else if (content.toolUse) {
754
853
  return React__namespace["default"].createElement(ToolContent, { toolUse: content.toolUse, key: index });
755
854
  }
@@ -812,7 +911,9 @@ const MessagesControl = () => {
812
911
  content.toolUse?.name.startsWith(RESPONSE_COMPONENT_PREFIX))) ?? [];
813
912
  return (React__namespace["default"].createElement(Layout, null, messagesWithRenderableContent?.map((message, index) => {
814
913
  return (React__namespace["default"].createElement(RoleContext.Provider, { value: message.role, key: `message-${index}` },
815
- React__namespace["default"].createElement(MessageContainer, { "data-testid": `message`, key: `message-${index}`, tabIndex: focusedItemIndex === index ? 0 : -1, onFocus: () => handleFocus(index), onKeyDown: (event) => onKeyDown(index, event), ref: (el) => (messagesRef.current[index] = el) },
914
+ React__namespace["default"].createElement(MessageContainer, { "data-testid": `message`, key: `message-${index}`, tabIndex: focusedItemIndex === index ? 0 : -1, onFocus: () => handleFocus(index), onKeyDown: (event) => onKeyDown(index, event), ref: (el) => {
915
+ messagesRef.current[index] = el;
916
+ } },
816
917
  React__namespace["default"].createElement(HeaderContainer, null,
817
918
  React__namespace["default"].createElement(AvatarControl, null),
818
919
  React__namespace["default"].createElement(Timestamp, null, getMessageTimestampText(new Date(message.createdAt)))),
@@ -986,6 +1087,7 @@ const MessageList = ({ messages, }) => {
986
1087
  const isLoading = React__namespace.useContext(LoadingContext);
987
1088
  const messagesWithRenderableContent = messages?.filter((message) => message.content.some((content) => content.image ??
988
1089
  content.text ??
1090
+ content.document ??
989
1091
  content.toolUse?.name.startsWith(RESPONSE_COMPONENT_PREFIX))) ?? [];
990
1092
  return (React__namespace.createElement(uiReact.View, { className: ui.ComponentClassName.AIConversationMessageList },
991
1093
  isLoading ? (React__namespace.createElement(React__namespace.Fragment, null,
@@ -997,8 +1099,11 @@ const MessageList = ({ messages, }) => {
997
1099
  const Attachment = ({ file, handleRemove, }) => {
998
1100
  const icons = internal.useIcons('aiConversation');
999
1101
  const removeIcon = icons?.remove ?? React__namespace.createElement(internal.IconClose, null);
1102
+ const fileIcon = icons?.document ?? React__namespace.createElement(internal.IconDocument, null);
1103
+ const format = getAttachmentFormat(file);
1104
+ const isDocument = documentFileTypes.has(format);
1000
1105
  return (React__namespace.createElement(uiReact.View, { className: ui.ComponentClassName.AIConversationAttachment },
1001
- React__namespace.createElement(uiReact.Image, { className: ui.ComponentClassName.AIConversationAttachmentImage, src: URL.createObjectURL(file), alt: file.name }),
1106
+ isDocument ? (fileIcon) : (React__namespace.createElement(uiReact.Image, { className: ui.ComponentClassName.AIConversationAttachmentImage, src: URL.createObjectURL(file), alt: file.name })),
1002
1107
  React__namespace.createElement(uiReact.Text, { as: "span", className: ui.ComponentClassName.AIConversationAttachmentName }, file.name),
1003
1108
  React__namespace.createElement(uiReact.Text, { as: "span", className: ui.ComponentClassName.AIConversationAttachmentSize }, ui.humanFileSize(file.size, true)),
1004
1109
  React__namespace.createElement(uiReact.Button, { size: "small", variation: "link", colorTheme: "error", className: ui.ComponentClassName.AIConversationAttachmentRemove, onClick: handleRemove }, removeIcon)));
@@ -1039,8 +1144,9 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
1039
1144
  const sendIcon = icons?.send ?? React__namespace.createElement(internal.IconSend, null);
1040
1145
  const attachIcon = icons?.attach ?? React__namespace.createElement(internal.IconAttach, null);
1041
1146
  const hiddenInput = React__namespace.useRef(null);
1147
+ // Bedrock does not accept message that are empty or are only whitespace
1148
+ const isInputEmpty = !input?.text?.length || !!input.text.match(/^\s+$/);
1042
1149
  const [composing, setComposing] = React__namespace.useState(false);
1043
- const isInputEmpty = !input?.text?.length && !input?.files?.length;
1044
1150
  return (React__namespace.createElement(FormWrapper, { onValidate: onValidate, allowAttachments: allowAttachments },
1045
1151
  React__namespace.createElement(uiReact.View, { as: "form", className: ui.ComponentClassName.AIConversationForm, onSubmit: handleSubmit },
1046
1152
  allowAttachments ? (React__namespace.createElement(uiReact.Button, { className: ui.ComponentClassName.AIConversationFormAttach, onClick: () => {
@@ -1056,7 +1162,7 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
1056
1162
  return;
1057
1163
  }
1058
1164
  onValidate(Array.from(e.target.files));
1059
- }, multiple: true, accept: ".jpeg,.png,.webp,.gif", "data-testid": "hidden-file-input" })))) : null,
1165
+ }, multiple: true, accept: [...validFileTypes].map((type) => `.${type}`).join(','), "data-testid": "hidden-file-input" })))) : null,
1060
1166
  React__namespace.createElement(uiReact.TextAreaField, { className: ui.ComponentClassName.AIConversationFormField, label: "input", labelHidden: true, autoResize: true, flex: "1", rows: 1, value: input?.text ?? '', testId: "text-input", onCompositionStart: () => setComposing(true), onCompositionEnd: () => setComposing(false), onKeyDown: (e) => {
1061
1167
  // Submit on enter key if shift is not pressed also
1062
1168
  const shouldSubmit = !e.shiftKey && e.key === 'Enter' && !composing;
@@ -1070,10 +1176,7 @@ const Form = ({ setInput, input, handleSubmit, allowAttachments, onValidate, isL
1070
1176
  text: e.target.value,
1071
1177
  }));
1072
1178
  } }),
1073
- React__namespace.createElement(uiReact.Button, { type: "submit", variation: "primary", className: ui.ComponentClassName.AIConversationFormSend,
1074
- // we intentionally || in the case where isLoading is false we should use the value of isInputEmpty
1075
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1076
- isDisabled: isLoading || isInputEmpty },
1179
+ React__namespace.createElement(uiReact.Button, { type: "submit", variation: "primary", className: ui.ComponentClassName.AIConversationFormSend, isDisabled: isLoading ?? isInputEmpty },
1077
1180
  React__namespace.createElement("span", null, sendIcon))),
1078
1181
  error ? (React__namespace.createElement(uiReact.Message, { className: ui.ComponentClassName.AIConversationFormError, variation: "plain", colorTheme: "warning" }, error)) : null,
1079
1182
  React__namespace.createElement(Attachments, { setInput: setInput, files: input?.files })));
@@ -1090,7 +1193,7 @@ const PromptList = ({ setInput, suggestedPrompts = [], }) => {
1090
1193
  })));
1091
1194
  };
1092
1195
 
1093
- const VERSION = '1.2.1';
1196
+ const VERSION = '1.4.0';
1094
1197
 
1095
1198
  function AIConversationBase({ avatars, controls, ...rest }) {
1096
1199
  uiReactCore.useSetUserAgent({
@@ -1232,7 +1335,7 @@ function createUseAIConversation(client) {
1232
1335
  const clientRoute = client.conversations[routeName];
1233
1336
  // We need to keep track of the stream events as the come in
1234
1337
  // for an assistant message, but don't need to keep them in state
1235
- const contentBlocksRef = React__namespace["default"].useRef();
1338
+ const contentBlocksRef = React__namespace["default"].useRef(undefined);
1236
1339
  // Using this hook without an existing conversation id means
1237
1340
  // it will create a new conversation when it is executed
1238
1341
  // we don't want to create 2 conversations
@@ -1292,16 +1395,17 @@ function createUseAIConversation(client) {
1292
1395
  // between the gen2 schema definition and
1293
1396
  // whats in amplify_outputs
1294
1397
  if (!clientRoute) {
1398
+ const error = {
1399
+ message: 'Conversation route does not exist',
1400
+ errorInfo: null,
1401
+ errorType: '',
1402
+ };
1295
1403
  setDataState({
1296
1404
  ...ERROR_STATE,
1297
1405
  data: { messages: [] },
1298
- messages: [
1299
- {
1300
- message: 'Conversation route does not exist',
1301
- errorInfo: null,
1302
- errorType: '',
1303
- },
1304
- ],
1406
+ // TODO in MV bump: remove `messages`
1407
+ messages: [error],
1408
+ errors: [error],
1305
1409
  });
1306
1410
  return;
1307
1411
  }
@@ -1415,7 +1519,9 @@ function createUseAIConversation(client) {
1415
1519
  return {
1416
1520
  ...prev,
1417
1521
  ...ERROR_STATE,
1522
+ // TODO in MV bump: remove `messages`
1418
1523
  messages: error.errors,
1524
+ errors: error.errors,
1419
1525
  };
1420
1526
  });
1421
1527
  },
@@ -1459,16 +1565,17 @@ function createUseAIConversation(client) {
1459
1565
  conversation.sendMessage(input);
1460
1566
  }
1461
1567
  else {
1568
+ const error = {
1569
+ message: 'No conversation found',
1570
+ errorInfo: null,
1571
+ errorType: '',
1572
+ };
1462
1573
  setDataState((prev) => ({
1463
1574
  ...prev,
1464
1575
  ...ERROR_STATE,
1465
- messages: [
1466
- {
1467
- message: 'No conversation found',
1468
- errorInfo: null,
1469
- errorType: '',
1470
- },
1471
- ],
1576
+ // TODO in MV bump: remove `messages`
1577
+ messages: [error],
1578
+ errors: [error],
1472
1579
  }));
1473
1580
  }
1474
1581
  }, [conversation]);
@@ -3,4 +3,4 @@ export declare const AIContextContext: React.Context<(() => object) | undefined>
3
3
  export declare const AIContextProvider: ({ children, aiContext, }: {
4
4
  children?: React.ReactNode;
5
5
  aiContext?: (() => object) | undefined;
6
- }) => JSX.Element;
6
+ }) => React.JSX.Element;
@@ -4,4 +4,4 @@ export declare const ActionsContext: React.Context<CustomAction[] | undefined>;
4
4
  export declare const ActionsProvider: ({ children, actions, }: {
5
5
  children?: React.ReactNode;
6
6
  actions?: CustomAction[] | undefined;
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
@@ -3,4 +3,4 @@ import { AIConversationInput } from '../types';
3
3
  export interface AttachmentContextProps extends Pick<AIConversationInput, 'allowAttachments' | 'maxAttachments' | 'maxAttachmentSize'> {
4
4
  }
5
5
  export declare const AttachmentContext: React.Context<Required<AttachmentContextProps>>;
6
- export declare const AttachmentProvider: ({ children, allowAttachments, maxAttachmentSize, maxAttachments, }: React.PropsWithChildren<AttachmentContextProps>) => JSX.Element;
6
+ export declare const AttachmentProvider: ({ children, allowAttachments, maxAttachmentSize, maxAttachments, }: React.PropsWithChildren<AttachmentContextProps>) => React.JSX.Element;
@@ -4,4 +4,4 @@ export declare const AvatarsContext: React.Context<Avatars | undefined>;
4
4
  export declare const AvatarsProvider: ({ children, avatars, }: {
5
5
  children?: React.ReactNode;
6
6
  avatars?: Avatars | undefined;
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
@@ -21,4 +21,4 @@ export declare const ControlsContext: React.Context<ControlsContextProps | undef
21
21
  export declare const ControlsProvider: ({ children, controls, }: {
22
22
  children?: React.ReactNode;
23
23
  controls?: ControlsContextProps | undefined;
24
- }) => JSX.Element;
24
+ }) => React.JSX.Element;
@@ -12,4 +12,4 @@ export interface ConversationInputContextProps {
12
12
  export declare const ConversationInputContext: React.Context<ConversationInputContextProps>;
13
13
  export declare const ConversationInputContextProvider: ({ children, }: {
14
14
  children?: React.ReactNode;
15
- }) => JSX.Element;
15
+ }) => React.JSX.Element;
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { ConversationDisplayText } from '../displayText';
3
2
  export declare const ConversationDisplayTextContext: import("react").Context<Required<ConversationDisplayText>>, ConversationDisplayTextProvider: import("react").ComponentType<import("react").PropsWithChildren<Required<ConversationDisplayText>>>, useConversationDisplayText: (params?: {
4
3
  errorMessage?: string | undefined;
@@ -4,4 +4,4 @@ export declare const FallbackComponentContext: React.Context<React.ComponentType
4
4
  export declare const FallbackComponentProvider: ({ children, FallbackComponent, }: {
5
5
  children?: React.ReactNode;
6
6
  FallbackComponent?: AIConversationInput['FallbackResponseComponent'];
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
@@ -3,4 +3,4 @@ export declare const LoadingContext: React.Context<boolean | undefined>;
3
3
  export declare const LoadingContextProvider: ({ children, isLoading, }: {
4
4
  children?: React.ReactNode;
5
5
  isLoading?: boolean | undefined;
6
- }) => JSX.Element;
6
+ }) => React.JSX.Element;
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { MessageRenderer } from '../types';
3
2
  export declare const MessageRendererContext: import("react").Context<MessageRenderer>, MessageRendererProvider: import("react").ComponentType<import("react").PropsWithChildren<MessageRenderer>>, useMessageRenderer: (params?: {
4
3
  errorMessage?: string | undefined;
@@ -4,4 +4,4 @@ export declare const MessageVariantContext: React.Context<MessageVariant | undef
4
4
  export declare const MessageVariantProvider: ({ children, variant, }: {
5
5
  children?: React.ReactNode;
6
6
  variant?: MessageVariant | undefined;
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
@@ -6,5 +6,5 @@ export declare const RoleContext: React.Context<"user" | "assistant" | undefined
6
6
  export declare const MessagesProvider: ({ children, messages, }: {
7
7
  children?: React.ReactNode;
8
8
  messages: ConversationMessage[];
9
- }) => JSX.Element;
9
+ }) => React.JSX.Element;
10
10
  export {};
@@ -7,6 +7,6 @@ export declare const prependResponseComponents: (responseComponents?: ResponseCo
7
7
  export declare const ResponseComponentsProvider: ({ children, responseComponents, }: {
8
8
  children?: React.ReactNode;
9
9
  responseComponents?: ResponseComponents | undefined;
10
- }) => JSX.Element;
10
+ }) => React.JSX.Element;
11
11
  export declare const convertResponseComponentsToToolConfiguration: (responseComponents?: ResponseComponents) => ToolConfiguration | undefined;
12
12
  export {};
@@ -4,4 +4,4 @@ export declare const SendMessageContext: React.Context<SendMessage | undefined>;
4
4
  export declare const SendMessageContextProvider: ({ children, handleSendMessage, }: {
5
5
  children?: React.ReactNode;
6
6
  handleSendMessage: SendMessage;
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
@@ -5,5 +5,5 @@ export declare const SuggestedPromptsContext: React.Context<SuggestedPromptsCont
5
5
  export declare const SuggestedPromptProvider: ({ children, suggestedPrompts, }: {
6
6
  children?: React.ReactNode;
7
7
  suggestedPrompts?: SuggestedPrompt[] | undefined;
8
- }) => JSX.Element;
8
+ }) => React.JSX.Element;
9
9
  export {};
@@ -4,5 +4,5 @@ export declare const WelcomeMessageContext: React.Context<WelcomeMessageContextP
4
4
  export declare const WelcomeMessageProvider: ({ children, welcomeMessage, }: {
5
5
  children?: React.ReactNode;
6
6
  welcomeMessage?: React.ReactNode;
7
- }) => JSX.Element;
7
+ }) => React.JSX.Element;
8
8
  export {};
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { IconElement } from './IconElement';
3
2
  export interface AIConversationElements {
4
3
  Button: typeof ButtonElement;
@@ -3,6 +3,7 @@ export type ConversationDisplayText = {
3
3
  getMessageTimestampText?: (date: Date) => string;
4
4
  getMaxAttachmentErrorText?: (count: number) => string;
5
5
  getAttachmentSizeErrorText?: (sizeText: string) => string;
6
+ getAttachmentFormatErrorText?: (formats: string[]) => string;
6
7
  };
7
8
  export declare const defaultAIConversationDisplayTextEn: Required<AIConversationDisplayText>;
8
9
  export type AIConversationDisplayText = DisplayTextTemplate<ConversationDisplayText>;
@@ -34,10 +34,10 @@ export interface AIConversationProps {
34
34
  aiContext?: () => object;
35
35
  }
36
36
  export interface AIConversation<PropsType extends AIConversationProps = AIConversationProps> {
37
- (props: PropsType): JSX.Element;
38
- DefaultMessage: () => JSX.Element | undefined;
39
- Messages: () => JSX.Element;
40
- Form: () => JSX.Element;
37
+ (props: PropsType): React.JSX.Element;
38
+ DefaultMessage: () => React.JSX.Element | undefined;
39
+ Messages: () => React.JSX.Element;
40
+ Form: () => React.JSX.Element;
41
41
  Provider: (props: AIConversationProviderProps) => React.JSX.Element;
42
42
  }
43
43
  export type MessageVariant = 'bubble' | 'default';
@@ -1,7 +1,13 @@
1
- import { ImageContentBlock } from '../../types';
1
+ import { ImageContentBlock, DocumentContentBlock } from '../../types';
2
2
  export declare function formatDate(date: Date): string;
3
3
  export declare function convertBufferToBase64(buffer: ArrayBuffer, format: ImageContentBlock['format']): string;
4
- export declare function getImageTypeFromMimeType(mimeType: string): 'png' | 'jpeg' | 'gif' | 'webp';
4
+ export declare function getAttachmentFormat(file: File): string;
5
+ export declare function getValidDocumentName(file: File): string;
6
+ export declare const documentFileTypes: Set<string>;
7
+ export declare const imageFileTypes: Set<string>;
8
+ export declare const validFileTypes: Set<string>;
9
+ export declare function isDocumentFormat(format: string): format is DocumentContentBlock['format'];
10
+ export declare function isImageFormat(format: string): format is ImageContentBlock['format'];
5
11
  export declare function attachmentsValidator({ files, maxAttachments, maxAttachmentSize, }: {
6
12
  files: File[];
7
13
  maxAttachments: number;
@@ -11,4 +17,5 @@ export declare function attachmentsValidator({ files, maxAttachments, maxAttachm
11
17
  rejectedFiles: File[];
12
18
  hasMaxAttachmentSizeError: boolean;
13
19
  hasMaxAttachmentsError: boolean;
20
+ hasUnsupportedFileError: boolean;
14
21
  }>;
@@ -1,10 +1,10 @@
1
- /// <reference types="react" />
1
+ import React from 'react';
2
2
  import { AIConversationElements } from '../../context/elements';
3
3
  export declare const RemoveButtonControl: RemoveButtonControl;
4
4
  interface RemoveButtonControl<T extends Partial<AIConversationElements> = AIConversationElements> {
5
5
  (props: {
6
6
  onRemove: () => void;
7
- }): JSX.Element;
7
+ }): React.JSX.Element;
8
8
  Button: T['Button'];
9
9
  Icon: T['Icon'];
10
10
  }
@@ -13,7 +13,7 @@ interface TextControl<T extends Partial<AIConversationElements> = AIConversation
13
13
  (props: {
14
14
  fileName: string;
15
15
  fileSize: number;
16
- }): JSX.Element;
16
+ }): React.JSX.Element;
17
17
  Container: T['View'];
18
18
  FileName: T['Text'];
19
19
  FileSize: T['Text'];
@@ -24,7 +24,7 @@ interface AttachmentControl<T extends Partial<AIConversationElements> = AIConver
24
24
  (props: {
25
25
  image: File;
26
26
  onRemove: () => void;
27
- }): JSX.Element;
27
+ }): React.JSX.Element;
28
28
  Container: T['ListItem'];
29
29
  ImageIcon: T['Icon'];
30
30
  RemoveButton: RemoveButtonControl<T>;
@@ -32,7 +32,7 @@ interface AttachmentControl<T extends Partial<AIConversationElements> = AIConver
32
32
  }
33
33
  export declare const AttachmentListControl: AttachmentListControl;
34
34
  export interface AttachmentListControl<T extends Partial<AIConversationElements> = AIConversationElements> {
35
- (): JSX.Element;
35
+ (): React.JSX.Element;
36
36
  List: T['UnorderedList'];
37
37
  Item: AttachmentControl<T>;
38
38
  }
@@ -1,2 +1,2 @@
1
- /// <reference types="react" />
2
- export declare const DefaultMessageControl: () => JSX.Element | undefined;
1
+ import * as React from 'react';
2
+ export declare const DefaultMessageControl: () => React.JSX.Element | undefined;
@@ -1,17 +1,18 @@
1
- /// <reference types="react" />
1
+ import React from 'react';
2
2
  import { AIConversationElements } from '../../context/elements';
3
3
  import { ActionsBarControl } from './ActionsBarControl';
4
4
  import { AvatarControl } from './AvatarControl';
5
5
  import { ConversationMessage } from '../../../../types';
6
+ export declare const DocumentContent: ({ format, name, source, }: NonNullable<ConversationMessage['content'][number]['document']>) => React.JSX.Element;
6
7
  export declare const MessageControl: MessageControl;
7
8
  interface MessageControl {
8
9
  (props: {
9
10
  message: ConversationMessage;
10
- }): JSX.Element;
11
+ }): React.JSX.Element;
11
12
  }
12
13
  export declare const MessagesControl: MessagesControl;
13
14
  export interface MessagesControl {
14
- (): JSX.Element;
15
+ (): React.JSX.Element;
15
16
  ActionsBar: ActionsBarControl;
16
17
  Avatar: AvatarControl;
17
18
  Container: AIConversationElements['View'];
@@ -1,6 +1,6 @@
1
- /// <reference types="react" />
1
+ import * as React from 'react';
2
2
  import { ConversationInputContextProps } from '../../context';
3
3
  export declare const Attachments: ({ files, setInput, }: {
4
4
  files?: File[] | undefined;
5
5
  setInput: ConversationInputContextProps['setInput'];
6
- }) => JSX.Element | null;
6
+ }) => React.JSX.Element | null;
@@ -1,7 +1,15 @@
1
1
  import { DataState } from '@aws-amplify/ui-react-core';
2
2
  import { GraphQLFormattedError } from '../types';
3
3
  export type DataClientState<T> = Omit<DataState<T>, 'message'> & {
4
+ /**
5
+ * @deprecated will be removed in a future major version. Superseded by `errors`
6
+ * @description errors returned from the websocket connection
7
+ */
4
8
  messages?: GraphQLFormattedError[];
9
+ /**
10
+ * @description errors returned from the websocket connection
11
+ */
12
+ errors?: GraphQLFormattedError[];
5
13
  };
6
14
  export type DataClientResponse<T> = {
7
15
  data: T | null;
@@ -1,4 +1,4 @@
1
- import { V6Client } from '@aws-amplify/api-graphql';
1
+ import type { ClientExtensions } from '@aws-amplify/data-schema/runtime';
2
2
  import { getSchema } from '../types';
3
3
  import { DataClientState } from './shared';
4
4
  export interface UseAIGenerationHookWrapper<Key extends keyof AIGenerationClient<Schema>['generations'], Schema extends Record<any, any>> {
@@ -11,6 +11,6 @@ export type UseAIGenerationHook<Key extends keyof AIGenerationClient<Schema>['ge
11
11
  Awaited<DataClientState<Schema[Key]['returnType']>>,
12
12
  (input: Schema[Key]['args']) => void
13
13
  ];
14
- type AIGenerationClient<T extends Record<any, any>> = Pick<V6Client<T>, 'generations'>;
14
+ type AIGenerationClient<T extends Record<any, any>> = Pick<ClientExtensions<T>, 'generations'>;
15
15
  export declare function createUseAIGeneration<Client extends Record<'generations' | 'conversations', Record<string, any>>, Schema extends getSchema<Client>>(client: Client): UseAIGenerationHook<keyof Client['generations'], Client>;
16
16
  export {};
@@ -1,6 +1,5 @@
1
- /// <reference types="react" />
2
- import { V6Client } from '@aws-amplify/api-graphql';
3
- export type ConversationRoute = V6Client<any>['conversations'][string];
1
+ import type { ClientExtensions } from '@aws-amplify/data-schema/runtime';
2
+ export type ConversationRoute = ClientExtensions<any>['conversations'][string];
4
3
  export type Conversation = NonNullable<Awaited<ReturnType<ConversationRoute['create']>>['data']>;
5
4
  export type ConversationStreamEvent = Parameters<Parameters<Conversation['onStreamEvent']>[0]['next']>[0];
6
5
  export type ConversationMessage = NonNullable<Awaited<ReturnType<Conversation['sendMessage']>>['data']> & {
@@ -9,6 +8,7 @@ export type ConversationMessage = NonNullable<Awaited<ReturnType<Conversation['s
9
8
  export type ConversationMessageContent = ConversationMessage['content'][number];
10
9
  export type TextContentBlock = NonNullable<ConversationMessageContent['text']>;
11
10
  export type ImageContentBlock = NonNullable<ConversationMessageContent['image']>;
11
+ export type DocumentContentBlock = NonNullable<ConversationMessageContent['document']>;
12
12
  export type ToolUseContent = NonNullable<ConversationMessageContent['toolUse']>;
13
13
  export type ToolResultContent = NonNullable<ConversationMessageContent['toolResult']>;
14
14
  export type InputContent = Exclude<Parameters<Conversation['sendMessage']>[0], string>['content'][number];
@@ -21,7 +21,7 @@ export interface SendMesageParameters {
21
21
  toolConfiguration?: ToolConfiguration;
22
22
  }
23
23
  export type SendMessage = (input: SendMesageParameters) => void;
24
- type AIClient<T extends Record<any, any>> = Pick<V6Client<T>, 'generations' | 'conversations'>;
24
+ type AIClient<T extends Record<any, any>> = Pick<ClientExtensions<T>, 'generations' | 'conversations'>;
25
25
  export type getSchema<T> = T extends AIClient<infer Schema> ? Schema : never;
26
26
  export interface GraphQLFormattedError {
27
27
  readonly message: string;
@@ -1 +1 @@
1
- export declare const VERSION = "1.2.1";
1
+ export declare const VERSION = "1.4.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-amplify/ui-react-ai",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/esm/index.mjs",
6
6
  "exports": {
@@ -42,32 +42,28 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "peerDependencies": {
45
- "@aws-amplify/api-graphql": "unstable",
45
+ "@aws-amplify/data-schema": "^1.19.0",
46
46
  "aws-amplify": "^6.9.0",
47
- "react": "^16.14.0 || ^17.0 || ^18.0",
48
- "react-dom": "^16.14.0 || ^17.0 || ^18.0"
47
+ "react": "^16.14 || ^17 || ^18 || ^19",
48
+ "react-dom": "^16.14 || ^17 || ^18 || ^19"
49
49
  },
50
50
  "dependencies": {
51
- "@aws-amplify/ui": "^6.7.1",
52
- "@aws-amplify/ui-react": "^6.8.1",
53
- "@aws-amplify/ui-react-core": "^3.2.1"
54
- },
55
- "devDependencies": {
56
- "@types/jest-when": "^3.5.0",
57
- "jest-when": "^3.5.1"
51
+ "@aws-amplify/ui": "^6.10.1",
52
+ "@aws-amplify/ui-react": "^6.11.0",
53
+ "@aws-amplify/ui-react-core": "^3.4.1"
58
54
  },
59
55
  "size-limit": [
60
56
  {
61
57
  "name": "AIConversation",
62
58
  "path": "dist/esm/index.mjs",
63
59
  "import": "{ AIConversation }",
64
- "limit": "25 kB"
60
+ "limit": "27 kB"
65
61
  },
66
62
  {
67
63
  "name": "createAIConversation",
68
64
  "path": "dist/esm/index.mjs",
69
65
  "import": "{ createAIConversation }",
70
- "limit": "7 kB"
66
+ "limit": "20 kB"
71
67
  }
72
68
  ]
73
69
  }