@aws-amplify/ui-react-ai 0.3.2 → 1.0.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 (71) hide show
  1. package/dist/esm/components/AIConversation/AIConversation.mjs +21 -33
  2. package/dist/esm/components/AIConversation/AIConversationProvider.mjs +22 -16
  3. package/dist/esm/components/AIConversation/context/AIContextContext.mjs +8 -0
  4. package/dist/esm/components/AIConversation/context/FallbackComponentContext.mjs +8 -0
  5. package/dist/esm/components/AIConversation/context/MessageRenderContext.mjs +9 -0
  6. package/dist/esm/components/AIConversation/context/ResponseComponentsContext.mjs +6 -2
  7. package/dist/esm/components/AIConversation/context/WelcomeMessageContext.mjs +8 -0
  8. package/dist/esm/components/AIConversation/createAIConversation.mjs +18 -22
  9. package/dist/esm/components/AIConversation/views/Controls/ActionsBarControl.mjs +6 -2
  10. package/dist/esm/components/AIConversation/views/Controls/AttachFileControl.mjs +5 -0
  11. package/dist/esm/components/AIConversation/views/Controls/AttachmentListControl.mjs +5 -0
  12. package/dist/esm/components/AIConversation/views/Controls/AvatarControl.mjs +5 -0
  13. package/dist/esm/components/AIConversation/views/Controls/DefaultMessageControl.mjs +31 -0
  14. package/dist/esm/components/AIConversation/views/Controls/{FieldControl.mjs → FormControl.mjs} +22 -13
  15. package/dist/esm/components/AIConversation/views/Controls/MessagesControl.mjs +42 -42
  16. package/dist/esm/components/AIConversation/views/Controls/PromptControl.mjs +9 -36
  17. package/dist/esm/components/AIConversation/views/default/Form.mjs +4 -5
  18. package/dist/esm/components/AIConversation/views/default/MessageList.mjs +34 -16
  19. package/dist/esm/components/AIConversation/views/default/PromptList.mjs +1 -1
  20. package/dist/esm/hooks/contentFromEvents.mjs +22 -0
  21. package/dist/esm/hooks/createAIHooks.mjs +0 -3
  22. package/dist/esm/hooks/exhaustivelyListMessages.mjs +19 -0
  23. package/dist/esm/hooks/shared.mjs +14 -0
  24. package/dist/esm/hooks/useAIConversation.mjs +246 -106
  25. package/dist/esm/hooks/useAIGeneration.mjs +1 -8
  26. package/dist/esm/index.mjs +0 -1
  27. package/dist/esm/version.mjs +3 -0
  28. package/dist/index.js +569 -444
  29. package/dist/types/components/AIConversation/AIConversation.d.ts +2 -19
  30. package/dist/types/components/AIConversation/AIConversationProvider.d.ts +2 -3
  31. package/dist/types/components/AIConversation/context/AIContextContext.d.ts +6 -0
  32. package/dist/types/components/AIConversation/context/ControlsContext.d.ts +1 -0
  33. package/dist/types/components/AIConversation/context/FallbackComponentContext.d.ts +7 -0
  34. package/dist/types/components/AIConversation/context/MessageRenderContext.d.ts +5 -0
  35. package/dist/types/components/AIConversation/context/ResponseComponentsContext.d.ts +2 -2
  36. package/dist/types/components/AIConversation/context/WelcomeMessageContext.d.ts +8 -0
  37. package/dist/types/components/AIConversation/context/elements/definitions.d.ts +1 -1
  38. package/dist/types/components/AIConversation/context/index.d.ts +5 -0
  39. package/dist/types/components/AIConversation/createAIConversation.d.ts +0 -3
  40. package/dist/types/components/AIConversation/index.d.ts +2 -1
  41. package/dist/types/components/AIConversation/types.d.ts +24 -36
  42. package/dist/types/components/AIConversation/utils.d.ts +2 -2
  43. package/dist/types/components/AIConversation/views/Controls/DefaultMessageControl.d.ts +2 -0
  44. package/dist/types/components/AIConversation/views/Controls/{FieldControl.d.ts → FormControl.d.ts} +2 -2
  45. package/dist/types/components/AIConversation/views/Controls/MessagesControl.d.ts +3 -9
  46. package/dist/types/components/AIConversation/views/Controls/PromptControl.d.ts +0 -3
  47. package/dist/types/components/AIConversation/views/Controls/index.d.ts +3 -4
  48. package/dist/types/components/AIConversation/views/default/Form.d.ts +1 -1
  49. package/dist/types/components/AIConversation/views/default/MessageList.d.ts +1 -1
  50. package/dist/types/components/AIConversation/views/default/PromptList.d.ts +1 -1
  51. package/dist/types/components/AIConversation/views/index.d.ts +2 -3
  52. package/dist/types/hooks/contentFromEvents.d.ts +2 -0
  53. package/dist/types/hooks/createAIHooks.d.ts +0 -3
  54. package/dist/types/hooks/exhaustivelyListMessages.d.ts +8 -0
  55. package/dist/types/hooks/index.d.ts +1 -2
  56. package/dist/types/hooks/shared.d.ts +23 -0
  57. package/dist/types/hooks/useAIConversation.d.ts +6 -4
  58. package/dist/types/hooks/useAIGeneration.d.ts +3 -13
  59. package/dist/types/index.d.ts +1 -1
  60. package/dist/types/types.d.ts +38 -7
  61. package/dist/types/version.d.ts +1 -0
  62. package/package.json +20 -6
  63. package/dist/ai-conversation-styles.css +0 -195
  64. package/dist/ai-conversation-styles.js +0 -2
  65. package/dist/esm/components/AIConversation/views/Controls/HeaderControl.mjs +0 -34
  66. package/dist/esm/components/AIConversation/views/ConversationView.mjs +0 -20
  67. package/dist/esm/hooks/AIContextProvider.mjs +0 -20
  68. package/dist/types/ai-conversation-styles.d.ts +0 -1
  69. package/dist/types/components/AIConversation/views/Controls/HeaderControl.d.ts +0 -9
  70. package/dist/types/components/AIConversation/views/ConversationView.d.ts +0 -2
  71. package/dist/types/hooks/AIContextProvider.d.ts +0 -17
@@ -1,128 +1,268 @@
1
1
  import React__default from 'react';
2
- import { useAIContext } from './AIContextProvider.mjs';
2
+ import { INITIAL_STATE, ERROR_STATE, LOADING_STATE } from './shared.mjs';
3
+ import { isFunction } from '@aws-amplify/ui';
4
+ import { contentFromEvents } from './contentFromEvents.mjs';
5
+ import { exhaustivelyListMessages } from './exhaustivelyListMessages.mjs';
3
6
 
4
- function createNewConversationMessageInRoute({ previousValue, routeName, conversationId, messages, }) {
5
- return {
6
- ...previousValue,
7
- [routeName]: {
8
- ...previousValue[routeName],
9
- [conversationId]: messages,
10
- },
11
- };
7
+ function hasStarted(state) {
8
+ return ['initialLoading', 'initialized'].includes(state);
12
9
  }
13
10
  function createUseAIConversation(client) {
11
+ // This is a bit complicated so buckle up.
12
+ // The way the data client works is conversation.get() or conversation.create()
13
+ // is an async function because it makes a graphql call to appsync
14
+ // then it returns a conversation object, which is like a normal
15
+ // data client record, except that it also has functions on it,
16
+ // like sendMessage and onStreamEvent. onStreamEvent sets up a
17
+ // subscription using a websocket connection, which ideally we only want to
18
+ // do once per conversation. Because we can only subscribe AFTER the
19
+ // async call to get/create the conversation is made, the cleanup
20
+ // function in the effect will won't actually unsubscribe
14
21
  const useAIConversation = (routeName, input = {}) => {
15
22
  const clientRoute = client.conversations[routeName];
16
- const { routeToConversationsMap, setRouteToConversationsMap } = useAIContext();
17
- const messagesFromAIContext = input.id
18
- ? routeToConversationsMap[routeName]?.[input.id]
19
- : undefined;
20
- const [localMessages, setLocalMessages] = React__default.useState(messagesFromAIContext ?? []);
21
- const [conversation, setConversation] = React__default.useState(undefined);
22
- const [waitingForAIResponse, setWaitingForAIResponse] = React__default.useState(false);
23
- const [errorMessage, setErrorMessage] = React__default.useState();
24
- const [hasError, setHasError] = React__default.useState(false);
25
- // On hook initialization get conversation and load all messages
23
+ // We need to keep track of the stream events as the come in
24
+ // for an assistant message, but don't need to keep them in state
25
+ const contentBlocksRef = React__default.useRef();
26
+ // Using this hook without an existing conversation id means
27
+ // it will create a new conversation when it is executed
28
+ // we don't want to create 2 conversations
29
+ const initRef = React__default.useRef('initial');
30
+ const [dataState, setDataState] = React__default.useState(() => ({
31
+ ...INITIAL_STATE,
32
+ data: { messages: [], conversation: undefined },
33
+ }));
34
+ const { conversation } = dataState.data;
35
+ const { id, onInitialize, onMessage } = input;
26
36
  React__default.useEffect(() => {
27
37
  async function initialize() {
28
- const { data: conversation } = input.id
29
- ? await clientRoute.get({ id: input.id })
30
- : await clientRoute.create();
31
- if (!conversation) {
32
- const errorString = 'No conversation found';
33
- setHasError(true);
34
- setErrorMessage(errorString);
35
- throw new Error(errorString);
38
+ // We don't want to run the effect multiple times
39
+ // because that could create multiple conversation records
40
+ if (hasStarted(initRef.current))
41
+ return;
42
+ initRef.current = 'initialLoading';
43
+ // Only show component loading state if we are
44
+ // actually loading messages
45
+ if (id) {
46
+ setDataState({
47
+ ...LOADING_STATE,
48
+ data: { messages: [], conversation: undefined },
49
+ });
36
50
  }
37
- const { data: messages } = await conversation.listMessages();
38
- setLocalMessages(messages);
39
- setConversation(conversation);
40
- setRouteToConversationsMap((previousValue) => {
41
- return createNewConversationMessageInRoute({
42
- previousValue,
43
- routeName: routeName,
44
- conversationId: conversation.id,
45
- messages,
51
+ const { data: conversation, errors } = id
52
+ ? await clientRoute.get({ id })
53
+ : await clientRoute.create();
54
+ if (errors ?? !conversation) {
55
+ setDataState({
56
+ ...ERROR_STATE,
57
+ data: { messages: [] },
58
+ messages: errors,
46
59
  });
60
+ }
61
+ else {
62
+ if (id) {
63
+ const { data: messages } = await exhaustivelyListMessages({
64
+ conversation,
65
+ });
66
+ setDataState({
67
+ ...INITIAL_STATE,
68
+ data: { messages, conversation },
69
+ });
70
+ }
71
+ else {
72
+ setDataState({
73
+ ...INITIAL_STATE,
74
+ data: { conversation, messages: [] },
75
+ });
76
+ }
77
+ initRef.current = 'initialized';
78
+ }
79
+ }
80
+ // this is a runtime guard to make catch an error if
81
+ // the route name wrong, or there is a mismatch
82
+ // between the gen2 schema definition and
83
+ // whats in amplify_outputs
84
+ if (!clientRoute) {
85
+ setDataState({
86
+ ...ERROR_STATE,
87
+ data: { messages: [] },
88
+ messages: [
89
+ {
90
+ message: 'Conversation route does not exist',
91
+ errorInfo: null,
92
+ errorType: '',
93
+ },
94
+ ],
47
95
  });
96
+ return;
48
97
  }
49
98
  initialize();
50
- }, [clientRoute, input.id, routeName, setRouteToConversationsMap]);
51
- // Update messages to match what is in AIContext if they aren't equal
99
+ return () => {
100
+ contentBlocksRef.current = undefined;
101
+ if (hasStarted(initRef.current))
102
+ return;
103
+ setDataState({
104
+ ...INITIAL_STATE,
105
+ data: { messages: [], conversation: undefined },
106
+ });
107
+ };
108
+ }, [clientRoute, id, setDataState]);
109
+ // Run a separate effect that is triggered by the conversation state
110
+ // so that we know we have a conversation object to set up the subscription
111
+ // and also unsubscribe on cleanup
52
112
  React__default.useEffect(() => {
53
- if (!!messagesFromAIContext && messagesFromAIContext !== localMessages)
54
- setLocalMessages(messagesFromAIContext);
55
- }, [messagesFromAIContext, localMessages]);
56
- const sendMessage = React__default.useCallback((input) => {
57
- const { content, aiContext, toolConfiguration } = input;
58
- conversation
59
- ?.sendMessage({ content, aiContext, toolConfiguration })
60
- .then((value) => {
61
- const { data: sentMessage } = value;
62
- if (sentMessage) {
63
- setWaitingForAIResponse(true);
64
- setLocalMessages((previousLocalMessages) => [
65
- ...previousLocalMessages,
66
- sentMessage,
67
- ]);
68
- setRouteToConversationsMap((previousValue) => {
69
- return createNewConversationMessageInRoute({
70
- previousValue,
71
- routeName: routeName,
72
- conversationId: conversation.id,
73
- messages: [
74
- ...previousValue[routeName][conversation.id],
75
- sentMessage,
76
- ],
113
+ if (!conversation)
114
+ return;
115
+ const subscription = conversation.onStreamEvent({
116
+ next: (event) => {
117
+ const {
118
+ // messages have a content block array,
119
+ // this is the index of the content block that was updated
120
+ contentBlockIndex,
121
+ // this is the index of the content chunk, ensure these are in order!
122
+ contentBlockDeltaIndex,
123
+ // this is sent after the last content chunk, verify this matches the
124
+ // previous contentBlockDeltaIndex
125
+ contentBlockDoneAtIndex,
126
+ // this is the final event of the conversation turn
127
+ stopReason, conversationId, id, } = event;
128
+ // return early for content blocks being done
129
+ // or conversation turn being over
130
+ if (contentBlockDoneAtIndex) {
131
+ return;
132
+ }
133
+ // stop reason will signify end of conversation turn
134
+ if (stopReason) {
135
+ // remove loading state from streamed message
136
+ setDataState((prev) => {
137
+ return {
138
+ ...prev,
139
+ data: {
140
+ ...prev.data,
141
+ messages: prev.data.messages.map((message) => ({
142
+ ...message,
143
+ isLoading: false,
144
+ })),
145
+ },
146
+ };
77
147
  });
78
- });
79
- }
80
- })
81
- .catch((reason) => {
82
- setHasError(true);
83
- setErrorMessage(`error sending message ${reason}`);
84
- });
85
- }, [conversation, routeName, setRouteToConversationsMap]);
86
- const subscribe = React__default.useCallback((handleStoreChange) => {
87
- const subscription = conversation &&
88
- conversation.onMessage((message) => {
89
- if (input.onResponse)
90
- input.onResponse(message);
91
- setWaitingForAIResponse(false);
92
- setLocalMessages((previousLocalMessages) => [
93
- ...previousLocalMessages,
94
- message,
95
- ]);
96
- setRouteToConversationsMap((previousValue) => {
97
- return createNewConversationMessageInRoute({
98
- previousValue,
99
- routeName: routeName,
100
- conversationId: conversation.id,
101
- messages: [
102
- ...previousValue[routeName][conversation.id],
103
- message,
104
- ],
148
+ onMessage?.({
149
+ id,
150
+ conversationId,
151
+ content: contentFromEvents(contentBlocksRef.current),
152
+ createdAt: new Date().toISOString(),
153
+ role: 'assistant',
154
+ isLoading: true,
105
155
  });
156
+ // clear out the stream cache
157
+ contentBlocksRef.current = undefined;
158
+ return;
159
+ }
160
+ // no ref means its the first event for the message stream
161
+ // so lets create the contentBlocks ref or else we will
162
+ // add the incoming event to the right content content block
163
+ if (!contentBlocksRef.current) {
164
+ contentBlocksRef.current = [[event]];
165
+ }
166
+ else {
167
+ // place the incoming event in the right content block
168
+ // and order. message content is an array so a single message
169
+ // can have multiple content blocks, and each content block
170
+ // can have multiple events/chunks
171
+ const currentBlock = contentBlocksRef.current[contentBlockIndex];
172
+ if (!currentBlock) {
173
+ contentBlocksRef.current[contentBlockIndex] = [event];
174
+ }
175
+ else {
176
+ contentBlocksRef.current[contentBlockIndex] = [
177
+ ...currentBlock.slice(0, contentBlockDeltaIndex),
178
+ event,
179
+ ...currentBlock.slice(contentBlockDeltaIndex),
180
+ ];
181
+ }
182
+ }
183
+ setDataState((prev) => {
184
+ const message = {
185
+ id,
186
+ conversationId,
187
+ content: contentFromEvents(contentBlocksRef.current),
188
+ createdAt: new Date().toISOString(),
189
+ role: 'assistant',
190
+ isLoading: true,
191
+ };
192
+ return {
193
+ ...prev,
194
+ data: {
195
+ ...prev.data,
196
+ // TODO: we are assuming we only update the last
197
+ // message, but maybe we should match it by message ID?
198
+ messages: [...prev.data.messages.slice(0, -1), message],
199
+ },
200
+ };
106
201
  });
107
- handleStoreChange(); // should cause a re-render
108
- });
202
+ },
203
+ error: (error) => {
204
+ setDataState((prev) => {
205
+ return {
206
+ ...prev,
207
+ ...ERROR_STATE,
208
+ messages: error.errors,
209
+ };
210
+ });
211
+ },
212
+ });
213
+ if (isFunction(onInitialize)) {
214
+ onInitialize(conversation);
215
+ }
109
216
  return () => {
110
- subscription?.unsubscribe();
217
+ contentBlocksRef.current = undefined;
218
+ subscription.unsubscribe();
111
219
  };
112
- }, [conversation, routeName, setRouteToConversationsMap, input]);
113
- const getSnapshot = React__default.useCallback(() => localMessages, [localMessages]);
114
- // Using useSyncExternalStore to subscribe to external data updates
115
- // Have to provide third optional argument in next - https://github.com/vercel/next.js/issues/54685
116
- const messagesFromStore = React__default.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
117
- return [
118
- {
119
- data: { messages: messagesFromStore },
120
- isLoading: waitingForAIResponse,
121
- message: errorMessage,
122
- hasError,
123
- },
124
- sendMessage,
125
- ];
220
+ }, [conversation, onInitialize, onMessage, setDataState]);
221
+ const handleSendMessage = React__default.useCallback((input) => {
222
+ const { content } = input;
223
+ if (conversation) {
224
+ setDataState((prevState) => ({
225
+ ...prevState,
226
+ data: {
227
+ ...prevState.data,
228
+ // optimistically add user and assistant messages
229
+ messages: [
230
+ ...prevState.data.messages,
231
+ {
232
+ content,
233
+ role: 'user',
234
+ createdAt: new Date().toISOString(),
235
+ id: 'temp-id',
236
+ conversationId: conversation.id ?? '',
237
+ },
238
+ {
239
+ content: [{ text: ' ' }],
240
+ role: 'assistant',
241
+ createdAt: new Date().toISOString(),
242
+ id: 'temp-id-2',
243
+ conversationId: conversation.id ?? '',
244
+ isLoading: true,
245
+ },
246
+ ],
247
+ },
248
+ }));
249
+ conversation.sendMessage(input);
250
+ }
251
+ else {
252
+ setDataState((prev) => ({
253
+ ...prev,
254
+ ...ERROR_STATE,
255
+ messages: [
256
+ {
257
+ message: 'No conversation found',
258
+ errorInfo: null,
259
+ errorType: '',
260
+ },
261
+ ],
262
+ }));
263
+ }
264
+ }, [conversation]);
265
+ return [dataState, handleSendMessage];
126
266
  };
127
267
  return useAIConversation;
128
268
  }
@@ -1,13 +1,6 @@
1
1
  import * as React from 'react';
2
+ import { INITIAL_STATE, LOADING_STATE, ERROR_STATE } from './shared.mjs';
2
3
 
3
- // default state
4
- const INITIAL_STATE = {
5
- hasError: false,
6
- isLoading: false,
7
- messages: undefined,
8
- };
9
- const LOADING_STATE = { hasError: false, isLoading: true, messages: undefined };
10
- const ERROR_STATE = { hasError: true, isLoading: false };
11
4
  function createUseAIGeneration(client) {
12
5
  const useAIGeneration = (routeName) => {
13
6
  const [dataState, setDataState] = React.useState(() => ({
@@ -1,4 +1,3 @@
1
1
  export { createAIConversation } from './components/AIConversation/createAIConversation.mjs';
2
2
  export { AIConversation } from './components/AIConversation/AIConversation.mjs';
3
- export { AIContextProvider } from './hooks/AIContextProvider.mjs';
4
3
  export { createAIHooks } from './hooks/createAIHooks.mjs';
@@ -0,0 +1,3 @@
1
+ const VERSION = '1.0.0';
2
+
3
+ export { VERSION };