@aws-amplify/ui-react-ai 1.3.0 → 1.4.1
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.
- package/dist/esm/components/AIConversation/AIConversation.mjs +1 -1
- package/dist/esm/components/AIConversation/context/elements/definitions.mjs +4 -7
- package/dist/esm/components/AIConversation/displayText.mjs +3 -0
- package/dist/esm/components/AIConversation/utils.mjs +58 -7
- package/dist/esm/components/AIConversation/views/Controls/FormControl.mjs +49 -18
- package/dist/esm/components/AIConversation/views/Controls/MessagesControl.mjs +17 -2
- package/dist/esm/components/AIConversation/views/default/Attachments.mjs +6 -2
- package/dist/esm/components/AIConversation/views/default/Form.mjs +5 -6
- package/dist/esm/components/AIConversation/views/default/MessageList.mjs +1 -0
- package/dist/esm/hooks/useAIConversation.mjs +34 -30
- package/dist/esm/hooks/useAIGeneration.mjs +5 -12
- package/dist/esm/version.mjs +1 -1
- package/dist/index.js +174 -79
- package/dist/types/components/AIConversation/displayText.d.ts +1 -0
- package/dist/types/components/AIConversation/utils.d.ts +9 -2
- package/dist/types/components/AIConversation/views/Controls/MessagesControl.d.ts +1 -0
- package/dist/types/hooks/shared.d.ts +15 -5
- package/dist/types/hooks/useAIConversation.d.ts +2 -2
- package/dist/types/hooks/useAIGeneration.d.ts +5 -5
- package/dist/types/types.d.ts +4 -3
- package/dist/types/version.d.ts +1 -1
- package/package.json +8 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { Flex, ScrollView } from '@aws-amplify/ui-react';
|
|
3
|
-
import { useIcons,
|
|
3
|
+
import { useIcons, IconUser, IconAssistant } from '@aws-amplify/ui-react/internal';
|
|
4
4
|
import { MessagesControl } from './views/Controls/MessagesControl.mjs';
|
|
5
5
|
import { FormControl } from './views/Controls/FormControl.mjs';
|
|
6
6
|
import { MessageList } from './views/default/MessageList.mjs';
|
|
@@ -17,15 +17,15 @@ const ListItemElement = defineBaseElementWithRef({
|
|
|
17
17
|
type: 'li',
|
|
18
18
|
displayName: 'ListItem',
|
|
19
19
|
});
|
|
20
|
-
|
|
20
|
+
defineBaseElementWithRef({
|
|
21
21
|
type: 'h2',
|
|
22
22
|
displayName: 'Title',
|
|
23
23
|
});
|
|
24
|
-
|
|
24
|
+
defineBaseElementWithRef({
|
|
25
25
|
type: 'img',
|
|
26
26
|
displayName: 'Image',
|
|
27
27
|
});
|
|
28
|
-
|
|
28
|
+
defineBaseElementWithRef({
|
|
29
29
|
type: 'input',
|
|
30
30
|
displayName: 'Input',
|
|
31
31
|
});
|
|
@@ -44,10 +44,7 @@ const TextAreaElement = defineBaseElementWithRef({
|
|
|
44
44
|
});
|
|
45
45
|
const AIConversationElements = {
|
|
46
46
|
Button: ButtonElement,
|
|
47
|
-
Heading: HeadingElement,
|
|
48
47
|
Icon: IconElement,
|
|
49
|
-
Input: InputElement,
|
|
50
|
-
Image: ImageElement,
|
|
51
48
|
Label: LabelElement,
|
|
52
49
|
ListItem: ListItemElement,
|
|
53
50
|
Span: SpanElement,
|
|
@@ -57,4 +54,4 @@ const AIConversationElements = {
|
|
|
57
54
|
View: ViewElement,
|
|
58
55
|
};
|
|
59
56
|
|
|
60
|
-
export { AIConversationElements, ButtonElement,
|
|
57
|
+
export { AIConversationElements, ButtonElement, LabelElement, ListItemElement, SpanElement, TextAreaElement, TextElement, UnorderedListElement, ViewElement };
|
|
@@ -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
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
130
|
+
if (isInputText) {
|
|
120
131
|
const textContent = {
|
|
121
132
|
text: input.text,
|
|
122
133
|
};
|
|
123
134
|
submittedContent.push(textContent);
|
|
124
135
|
}
|
|
125
|
-
if (
|
|
136
|
+
if (isInputFiles) {
|
|
126
137
|
for (const file of input.files) {
|
|
127
138
|
const buffer = await file.arrayBuffer();
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
}
|
|
@@ -143,4 +158,4 @@ MessagesControl.HeaderContainer = HeaderContainer;
|
|
|
143
158
|
MessagesControl.Layout = Layout;
|
|
144
159
|
MessagesControl.Message = MessageControl;
|
|
145
160
|
|
|
146
|
-
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:
|
|
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,
|
|
@@ -27,11 +27,11 @@ function createUseAIConversation(client) {
|
|
|
27
27
|
// it will create a new conversation when it is executed
|
|
28
28
|
// we don't want to create 2 conversations
|
|
29
29
|
const initRef = React__default.useRef('initial');
|
|
30
|
-
const [
|
|
30
|
+
const [clientState, setClientState] = React__default.useState(() => ({
|
|
31
31
|
...INITIAL_STATE,
|
|
32
32
|
data: { messages: [], conversation: undefined },
|
|
33
33
|
}));
|
|
34
|
-
const { conversation } =
|
|
34
|
+
const { conversation } = clientState.data;
|
|
35
35
|
const { id, onInitialize, onMessage } = input;
|
|
36
36
|
React__default.useEffect(() => {
|
|
37
37
|
async function initialize() {
|
|
@@ -43,7 +43,7 @@ function createUseAIConversation(client) {
|
|
|
43
43
|
// Only show component loading state if we are
|
|
44
44
|
// actually loading messages
|
|
45
45
|
if (id) {
|
|
46
|
-
|
|
46
|
+
setClientState({
|
|
47
47
|
...LOADING_STATE,
|
|
48
48
|
data: { messages: [], conversation: undefined },
|
|
49
49
|
});
|
|
@@ -52,7 +52,7 @@ function createUseAIConversation(client) {
|
|
|
52
52
|
? await clientRoute.get({ id })
|
|
53
53
|
: await clientRoute.create();
|
|
54
54
|
if (errors ?? !conversation) {
|
|
55
|
-
|
|
55
|
+
setClientState({
|
|
56
56
|
...ERROR_STATE,
|
|
57
57
|
data: { messages: [] },
|
|
58
58
|
messages: errors,
|
|
@@ -63,13 +63,13 @@ function createUseAIConversation(client) {
|
|
|
63
63
|
const { data: messages } = await exhaustivelyListMessages({
|
|
64
64
|
conversation,
|
|
65
65
|
});
|
|
66
|
-
|
|
66
|
+
setClientState({
|
|
67
67
|
...INITIAL_STATE,
|
|
68
68
|
data: { messages, conversation },
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
else {
|
|
72
|
-
|
|
72
|
+
setClientState({
|
|
73
73
|
...INITIAL_STATE,
|
|
74
74
|
data: { conversation, messages: [] },
|
|
75
75
|
});
|
|
@@ -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
|
-
|
|
85
|
+
const error = {
|
|
86
|
+
message: 'Conversation route does not exist',
|
|
87
|
+
errorInfo: null,
|
|
88
|
+
errorType: '',
|
|
89
|
+
};
|
|
90
|
+
setClientState({
|
|
86
91
|
...ERROR_STATE,
|
|
87
92
|
data: { messages: [] },
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
|
@@ -100,12 +101,12 @@ function createUseAIConversation(client) {
|
|
|
100
101
|
contentBlocksRef.current = undefined;
|
|
101
102
|
if (hasStarted(initRef.current))
|
|
102
103
|
return;
|
|
103
|
-
|
|
104
|
+
setClientState({
|
|
104
105
|
...INITIAL_STATE,
|
|
105
106
|
data: { messages: [], conversation: undefined },
|
|
106
107
|
});
|
|
107
108
|
};
|
|
108
|
-
}, [clientRoute, id,
|
|
109
|
+
}, [clientRoute, id, setClientState]);
|
|
109
110
|
// Run a separate effect that is triggered by the conversation state
|
|
110
111
|
// so that we know we have a conversation object to set up the subscription
|
|
111
112
|
// and also unsubscribe on cleanup
|
|
@@ -133,7 +134,7 @@ function createUseAIConversation(client) {
|
|
|
133
134
|
// stop reason will signify end of conversation turn
|
|
134
135
|
if (stopReason) {
|
|
135
136
|
// remove loading state from streamed message
|
|
136
|
-
|
|
137
|
+
setClientState((prev) => {
|
|
137
138
|
return {
|
|
138
139
|
...prev,
|
|
139
140
|
data: {
|
|
@@ -180,7 +181,7 @@ function createUseAIConversation(client) {
|
|
|
180
181
|
];
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
|
-
|
|
184
|
+
setClientState((prev) => {
|
|
184
185
|
const message = {
|
|
185
186
|
id,
|
|
186
187
|
conversationId,
|
|
@@ -201,11 +202,13 @@ function createUseAIConversation(client) {
|
|
|
201
202
|
});
|
|
202
203
|
},
|
|
203
204
|
error: (error) => {
|
|
204
|
-
|
|
205
|
+
setClientState((prev) => {
|
|
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
|
},
|
|
@@ -217,11 +220,11 @@ function createUseAIConversation(client) {
|
|
|
217
220
|
contentBlocksRef.current = undefined;
|
|
218
221
|
subscription.unsubscribe();
|
|
219
222
|
};
|
|
220
|
-
}, [conversation, onInitialize, onMessage,
|
|
223
|
+
}, [conversation, onInitialize, onMessage, setClientState]);
|
|
221
224
|
const handleSendMessage = React__default.useCallback((input) => {
|
|
222
225
|
const { content } = input;
|
|
223
226
|
if (conversation) {
|
|
224
|
-
|
|
227
|
+
setClientState((prevState) => ({
|
|
225
228
|
...prevState,
|
|
226
229
|
data: {
|
|
227
230
|
...prevState.data,
|
|
@@ -249,20 +252,21 @@ function createUseAIConversation(client) {
|
|
|
249
252
|
conversation.sendMessage(input);
|
|
250
253
|
}
|
|
251
254
|
else {
|
|
252
|
-
|
|
255
|
+
const error = {
|
|
256
|
+
message: 'No conversation found',
|
|
257
|
+
errorInfo: null,
|
|
258
|
+
errorType: '',
|
|
259
|
+
};
|
|
260
|
+
setClientState((prev) => ({
|
|
253
261
|
...prev,
|
|
254
262
|
...ERROR_STATE,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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]);
|
|
265
|
-
return [
|
|
269
|
+
return [clientState, handleSendMessage];
|
|
266
270
|
};
|
|
267
271
|
return useAIConversation;
|
|
268
272
|
}
|
|
@@ -3,26 +3,19 @@ import { INITIAL_STATE, LOADING_STATE, ERROR_STATE } from './shared.mjs';
|
|
|
3
3
|
|
|
4
4
|
function createUseAIGeneration(client) {
|
|
5
5
|
const useAIGeneration = (routeName) => {
|
|
6
|
-
const [
|
|
7
|
-
...INITIAL_STATE,
|
|
8
|
-
data: undefined,
|
|
9
|
-
}));
|
|
6
|
+
const [clientState, setClientState] = React.useState(() => ({ ...INITIAL_STATE, data: undefined }));
|
|
10
7
|
const handleGeneration = React.useCallback(async (input) => {
|
|
11
|
-
|
|
8
|
+
setClientState(({ data }) => ({ ...LOADING_STATE, data }));
|
|
12
9
|
const result = await client.generations[routeName](input);
|
|
13
10
|
const { data, errors } = result;
|
|
14
11
|
if (errors) {
|
|
15
|
-
|
|
16
|
-
...ERROR_STATE,
|
|
17
|
-
data,
|
|
18
|
-
messages: errors,
|
|
19
|
-
});
|
|
12
|
+
setClientState({ ...ERROR_STATE, data, messages: errors });
|
|
20
13
|
}
|
|
21
14
|
else {
|
|
22
|
-
|
|
15
|
+
setClientState({ ...INITIAL_STATE, data });
|
|
23
16
|
}
|
|
24
17
|
}, [routeName]);
|
|
25
|
-
return [
|
|
18
|
+
return [clientState, handleGeneration];
|
|
26
19
|
};
|
|
27
20
|
return useAIGeneration;
|
|
28
21
|
}
|
package/dist/esm/version.mjs
CHANGED