|
1 | 1 | import { AIMessageComposer } from '@stream-io/chat-react-ai'; |
| 2 | +import { useEffect } from 'react'; |
2 | 3 | import { |
| 4 | + isImageFile, |
| 5 | + type Channel, |
| 6 | + type LocalUploadAttachment, |
| 7 | + type UploadRequestFn, |
| 8 | +} from 'stream-chat'; |
| 9 | +import { |
| 10 | + useAttachmentsForPreview, |
3 | 11 | useChannelActionContext, |
4 | 12 | useChannelStateContext, |
| 13 | + useChatContext, |
5 | 14 | useMessageComposer, |
6 | 15 | } from 'stream-chat-react'; |
7 | 16 | import { startAiAgent } from '../../api.ts'; |
8 | 17 | import './MessageInputBar.scss'; |
9 | 18 |
|
| 19 | +const isWatchedByAI = (channel: Channel) => { |
| 20 | + return Object.keys(channel.state.watchers).some((watcher) => |
| 21 | + watcher.startsWith('ai-bot'), |
| 22 | + ); |
| 23 | +}; |
| 24 | + |
10 | 25 | export const MessageInputBar = () => { |
| 26 | + const { client } = useChatContext(); |
11 | 27 | const { updateMessage, sendMessage } = useChannelActionContext(); |
12 | 28 | const { channel } = useChannelStateContext(); |
13 | 29 | const composer = useMessageComposer(); |
14 | 30 |
|
| 31 | + const { attachments } = useAttachmentsForPreview(); |
| 32 | + |
| 33 | + useEffect(() => { |
| 34 | + if (!composer) return; |
| 35 | + |
| 36 | + const upload: UploadRequestFn = (file) => { |
| 37 | + const f = isImageFile(file) ? client.uploadImage : client.uploadFile; |
| 38 | + |
| 39 | + return f.call(client, file as File); |
| 40 | + }; |
| 41 | + |
| 42 | + const previousDefault = composer.attachmentManager.doDefaultUploadRequest; |
| 43 | + |
| 44 | + composer.attachmentManager.setCustomUploadFn(upload); |
| 45 | + |
| 46 | + return () => composer.attachmentManager.setCustomUploadFn(previousDefault); |
| 47 | + }, [client, composer]); |
| 48 | + |
15 | 49 | return ( |
16 | 50 | <div className="ai-demo-message-input-bar"> |
17 | 51 | <AIMessageComposer |
| 52 | + onChange={(e) => { |
| 53 | + const input = e.currentTarget.elements.namedItem( |
| 54 | + 'attachments', |
| 55 | + ) as HTMLInputElement | null; |
| 56 | + |
| 57 | + const files = input?.files ?? null; |
| 58 | + |
| 59 | + if (files) { |
| 60 | + composer.attachmentManager.uploadFiles(files); |
| 61 | + } |
| 62 | + }} |
18 | 63 | onSubmit={async (e) => { |
19 | 64 | const event = e; |
20 | | - const target = (event.currentTarget ?? |
21 | | - event.target) as HTMLFormElement | null; |
22 | 65 | event.preventDefault(); |
23 | 66 |
|
24 | | - const formData = new FormData(event.currentTarget); |
| 67 | + const target = event.currentTarget; |
| 68 | + |
| 69 | + const formData = new FormData(target); |
25 | 70 |
|
26 | | - const t = formData.get('message'); |
| 71 | + const message = formData.get('message'); |
27 | 72 | const model = formData.get('model'); |
28 | 73 |
|
29 | | - composer.textComposer.setText(t as string); |
| 74 | + composer.textComposer.setText(message as string); |
30 | 75 |
|
31 | | - const d = await composer.compose(); |
| 76 | + const composedData = await composer.compose(); |
32 | 77 |
|
33 | | - if (!d) return; |
| 78 | + if (!composedData) return; |
34 | 79 |
|
35 | | - target?.reset(); |
| 80 | + target.reset(); |
36 | 81 | composer.clear(); |
37 | 82 |
|
38 | | - if (channel.initialized) { |
39 | | - const isAiAgentActive = Object.keys(channel.state.watchers).some( |
40 | | - (userId) => userId.startsWith('ai-bot'), |
41 | | - ); |
42 | | - if (!isAiAgentActive) { |
43 | | - await startAiAgent(channel, model); |
44 | | - } |
45 | | - |
46 | | - await sendMessage(d); |
47 | | - } else { |
48 | | - updateMessage(d?.localMessage); |
| 83 | + updateMessage(composedData?.localMessage); |
49 | 84 |
|
| 85 | + if (!channel.initialized) { |
50 | 86 | await channel.watch(); |
| 87 | + } |
51 | 88 |
|
52 | | - // TODO: wrap in retry (in case channel creation takes longer) |
| 89 | + if (!isWatchedByAI(channel)) { |
53 | 90 | await startAiAgent(channel, model); |
54 | | - |
55 | | - await sendMessage(d); |
56 | 91 | } |
| 92 | + |
| 93 | + await sendMessage(composedData); |
57 | 94 | }} |
58 | | - /> |
| 95 | + > |
| 96 | + <AIMessageComposer.AttachmentPreview> |
| 97 | + {attachments.map((attachment) => ( |
| 98 | + <AIMessageComposer.AttachmentPreview.Item |
| 99 | + key={attachment.localMetadata.id} |
| 100 | + file={attachment.localMetadata.file as File} |
| 101 | + state={attachment.localMetadata.uploadState} |
| 102 | + imagePreviewSource={ |
| 103 | + attachment.thumb_url || |
| 104 | + (attachment.localMetadata.previewUri as string) |
| 105 | + } |
| 106 | + onDelete={() => { |
| 107 | + composer.attachmentManager.removeAttachments([ |
| 108 | + attachment.localMetadata.id, |
| 109 | + ]); |
| 110 | + }} |
| 111 | + onRetry={() => { |
| 112 | + composer.attachmentManager.uploadAttachment( |
| 113 | + attachment as LocalUploadAttachment, |
| 114 | + ); |
| 115 | + }} |
| 116 | + /> |
| 117 | + ))} |
| 118 | + </AIMessageComposer.AttachmentPreview> |
| 119 | + <AIMessageComposer.TextInput name="message" /> |
| 120 | + <div |
| 121 | + style={{ |
| 122 | + display: 'flex', |
| 123 | + gap: '1rem', |
| 124 | + justifyContent: 'space-between', |
| 125 | + alignItems: 'center', |
| 126 | + }} |
| 127 | + > |
| 128 | + <div style={{ display: 'flex', gap: '.25rem', alignItems: 'center' }}> |
| 129 | + <AIMessageComposer.FileInput name="attachments" /> |
| 130 | + <AIMessageComposer.SpeechToTextButton /> |
| 131 | + <AIMessageComposer.ModelSelect name="model" /> |
| 132 | + </div> |
| 133 | + |
| 134 | + <AIMessageComposer.SubmitButton /> |
| 135 | + </div> |
| 136 | + </AIMessageComposer> |
59 | 137 | </div> |
60 | 138 | ); |
61 | 139 | }; |
0 commit comments