Skip to content

Commit fb1bfdd

Browse files
authored
feat: add support for ai generated messages (#2570)
1 parent 36c34cb commit fb1bfdd

32 files changed

+342
-46
lines changed

examples/vite/src/App.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
22
import {
3+
AIStateIndicator,
34
Channel,
45
ChannelAvatar,
56
ChannelHeader,
@@ -88,6 +89,7 @@ const App = () => {
8889
<Window>
8990
<ChannelHeader Avatar={ChannelAvatar} />
9091
<MessageList returnAllReadData />
92+
<AIStateIndicator />
9193
<MessageInput focus />
9294
</Window>
9395
<Thread virtualized />

examples/vite/src/index.scss

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ body,
2929
width: 0;
3030
flex-shrink: 0;
3131
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
32+
max-width: 1000px;
3233

3334
&--open {
3435
width: 30%;

package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@
145145
"emoji-mart": "^5.4.0",
146146
"react": "^18.0.0 || ^17.0.0 || ^16.8.0",
147147
"react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0",
148-
"stream-chat": "^8.45.0"
148+
"stream-chat": "^8.46.0"
149149
},
150150
"peerDependenciesMeta": {
151151
"@breezystack/lamejs": {
@@ -187,7 +187,7 @@
187187
"@semantic-release/exec": "^6.0.3",
188188
"@semantic-release/git": "^10.0.1",
189189
"@stream-io/rollup-plugin-node-builtins": "^2.1.5",
190-
"@stream-io/stream-chat-css": "^5.4.0",
190+
"@stream-io/stream-chat-css": "^5.5.0",
191191
"@testing-library/jest-dom": "^6.1.4",
192192
"@testing-library/react": "^13.1.1",
193193
"@testing-library/react-hooks": "^8.0.0",
@@ -257,7 +257,7 @@
257257
"react-dom": "^18.1.0",
258258
"react-test-renderer": "^18.1.0",
259259
"semantic-release": "^19.0.5",
260-
"stream-chat": "^8.45.0",
260+
"stream-chat": "^8.46.0",
261261
"ts-jest": "^29.1.4",
262262
"typescript": "^5.4.5"
263263
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
3+
import { Channel } from 'stream-chat';
4+
5+
import { AIStates, useAIState } from './hooks/useAIState';
6+
7+
import { useChannelStateContext, useTranslationContext } from '../../context';
8+
import type { DefaultStreamChatGenerics } from '../../types/types';
9+
10+
export type AIStateIndicatorProps<
11+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
12+
> = {
13+
channel?: Channel<StreamChatGenerics>;
14+
};
15+
16+
export const AIStateIndicator = <
17+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
18+
>({
19+
channel: channelFromProps,
20+
}: AIStateIndicatorProps<StreamChatGenerics>) => {
21+
const { t } = useTranslationContext();
22+
const { channel: channelFromContext } = useChannelStateContext<StreamChatGenerics>(
23+
'AIStateIndicator',
24+
);
25+
const channel = channelFromProps || channelFromContext;
26+
const { aiState } = useAIState(channel);
27+
const allowedStates = {
28+
[AIStates.Thinking]: t('Thinking...'),
29+
[AIStates.Generating]: t('Generating...'),
30+
};
31+
32+
return aiState in allowedStates ? (
33+
<div className='str-chat__ai-state-indicator-container'>
34+
<p className='str-chat__ai-state-indicator-text'>{allowedStates[aiState]}</p>
35+
</div>
36+
) : null;
37+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { AIState, Channel, Event } from 'stream-chat';
4+
5+
import type { DefaultStreamChatGenerics } from '../../../types/types';
6+
7+
export const AIStates = {
8+
Error: 'AI_STATE_ERROR',
9+
ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
10+
Generating: 'AI_STATE_GENERATING',
11+
Idle: 'AI_STATE_IDLE',
12+
Thinking: 'AI_STATE_THINKING',
13+
};
14+
15+
/**
16+
* A hook that returns the current state of the AI.
17+
* @param {Channel} channel - The channel for which we want to know the AI state.
18+
* @returns {{ aiState: AIState }} The current AI state for the given channel.
19+
*/
20+
export const useAIState = <
21+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
22+
>(
23+
channel?: Channel<StreamChatGenerics>,
24+
): { aiState: AIState } => {
25+
const [aiState, setAiState] = useState<AIState>(AIStates.Idle);
26+
27+
useEffect(() => {
28+
if (!channel) {
29+
return;
30+
}
31+
32+
const indicatorChangedListener = channel.on(
33+
'ai_indicator.update',
34+
(event: Event<StreamChatGenerics>) => {
35+
const { cid } = event;
36+
const state = event.ai_state as AIState;
37+
if (channel.cid === cid) {
38+
setAiState(state);
39+
}
40+
},
41+
);
42+
43+
const indicatorClearedListener = channel.on('ai_indicator.clear', (event) => {
44+
const { cid } = event;
45+
if (channel.cid === cid) {
46+
setAiState(AIStates.Idle);
47+
}
48+
});
49+
50+
return () => {
51+
indicatorChangedListener.unsubscribe();
52+
indicatorClearedListener.unsubscribe();
53+
};
54+
}, [channel]);
55+
56+
return { aiState };
57+
};
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './AIStateIndicator';
2+
export * from './hooks/useAIState';

src/components/Channel/Channel.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ type ChannelPropsForwardedToComponentContext<
161161
| 'UnreadMessagesNotification'
162162
| 'UnreadMessagesSeparator'
163163
| 'VirtualMessage'
164+
| 'StopAIGenerationButton'
165+
| 'StreamedMessageText'
164166
>;
165167

166168
const isUserResponseArray = <
@@ -1273,6 +1275,8 @@ const ChannelInner = <
12731275
ReactionsList: props.ReactionsList,
12741276
SendButton: props.SendButton,
12751277
StartRecordingAudioButton: props.StartRecordingAudioButton,
1278+
StopAIGenerationButton: props.StopAIGenerationButton,
1279+
StreamedMessageText: props.StreamedMessageText,
12761280
ThreadHead: props.ThreadHead,
12771281
ThreadHeader: props.ThreadHeader,
12781282
ThreadStart: props.ThreadStart,
@@ -1339,6 +1343,8 @@ const ChannelInner = <
13391343
props.UnreadMessagesNotification,
13401344
props.UnreadMessagesSeparator,
13411345
props.VirtualMessage,
1346+
props.StopAIGenerationButton,
1347+
props.StreamedMessageText,
13421348
props.emojiSearchIndex,
13431349
props.reactionOptions,
13441350
],

src/components/ChannelPreview/utils.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export const getLatestMessagePreview = <
7777
}
7878

7979
if (previewTextToRender) {
80-
return renderPreviewText(previewTextToRender);
80+
return latestMessage.ai_generated
81+
? previewTextToRender
82+
: renderPreviewText(previewTextToRender);
8183
}
8284

8385
if (latestMessage.command) {

src/components/Message/MessageSimple.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp';
3434

3535
import type { MessageUIComponentProps } from './types';
3636
import type { DefaultStreamChatGenerics } from '../../types/types';
37+
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
3738

3839
type MessageSimpleWithContextProps<
3940
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -81,6 +82,7 @@ const MessageSimpleWithContext = <
8182
MessageStatus = DefaultMessageStatus,
8283
MessageTimestamp = DefaultMessageTimestamp,
8384
ReactionsList = DefaultReactionList,
85+
StreamedMessageText = DefaultStreamedMessageText,
8486
PinIndicator,
8587
} = useComponentContext<StreamChatGenerics>('MessageSimple');
8688

@@ -185,7 +187,11 @@ const MessageSimpleWithContext = <
185187
{message.attachments?.length && !message.quoted_message ? (
186188
<Attachment actionHandler={handleAction} attachments={message.attachments} />
187189
) : null}
188-
<MessageText message={message} renderText={renderText} />
190+
{message.ai_generated ? (
191+
<StreamedMessageText message={message} renderText={renderText} />
192+
) : (
193+
<MessageText message={message} renderText={renderText} />
194+
)}
189195
{message.mml && (
190196
<MML
191197
actionHandler={handleAction}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
3+
import { MessageText, MessageTextProps } from './MessageText';
4+
import type { DefaultStreamChatGenerics } from '../../types/types';
5+
import { useMessageContext } from '../../context';
6+
import { useMessageTextStreaming } from './hooks';
7+
8+
export type StreamedMessageTextProps<
9+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
10+
> = Pick<MessageTextProps<StreamChatGenerics>, 'message' | 'renderText'> & {
11+
renderingLetterCount?: number;
12+
streamingLetterIntervalMs?: number;
13+
};
14+
15+
export const StreamedMessageText = <
16+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
17+
>(
18+
props: StreamedMessageTextProps<StreamChatGenerics>,
19+
) => {
20+
const {
21+
message: messageFromProps,
22+
renderingLetterCount,
23+
renderText,
24+
streamingLetterIntervalMs,
25+
} = props;
26+
const { message: messageFromContext } = useMessageContext<StreamChatGenerics>(
27+
'StreamedMessageText',
28+
);
29+
const message = messageFromProps || messageFromContext;
30+
const { text = '' } = message;
31+
const { streamedMessageText } = useMessageTextStreaming({
32+
renderingLetterCount,
33+
streamingLetterIntervalMs,
34+
text,
35+
});
36+
37+
return (
38+
<MessageText message={{ ...message, text: streamedMessageText }} renderText={renderText} />
39+
);
40+
};

src/components/Message/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './useRetryHandler';
1212
export * from './useUserHandler';
1313
export * from './useUserRole';
1414
export * from './useReactionsFetcher';
15+
export * from './useMessageTextStreaming';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
import type { DefaultStreamChatGenerics } from '../../../types/types';
4+
import type { StreamedMessageTextProps } from '../StreamedMessageText';
5+
6+
export type UseMessageTextStreamingProps<
7+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
8+
> = Pick<
9+
StreamedMessageTextProps<StreamChatGenerics>,
10+
'streamingLetterIntervalMs' | 'renderingLetterCount'
11+
> & { text: string };
12+
13+
const DEFAULT_LETTER_INTERVAL = 30;
14+
const DEFAULT_RENDERING_LETTER_COUNT = 2;
15+
16+
/**
17+
* A hook that returns text in a streamed, typewriter fashion. The speed of streaming is
18+
* configurable.
19+
* @param {number} [streamingLetterIntervalMs=30] - The timeout between each typing animation in milliseconds.
20+
* @param {number} [renderingLetterCount=2] - The number of letters to be rendered each time we update.
21+
* @param {string} text - The text that we want to render in a typewriter fashion.
22+
* @returns {{ streamedMessageText: string }} - A substring of the text property, up until we've finished rendering the typewriter animation.
23+
*/
24+
export const useMessageTextStreaming = <
25+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
26+
>({
27+
streamingLetterIntervalMs = DEFAULT_LETTER_INTERVAL,
28+
renderingLetterCount = DEFAULT_RENDERING_LETTER_COUNT,
29+
text,
30+
}: UseMessageTextStreamingProps<StreamChatGenerics>): { streamedMessageText: string } => {
31+
const [streamedMessageText, setStreamedMessageText] = useState<string>(text);
32+
const textCursor = useRef<number>(text.length);
33+
34+
useEffect(() => {
35+
const textLength = text.length;
36+
const interval = setInterval(() => {
37+
if (!text || textCursor.current >= textLength) {
38+
clearInterval(interval);
39+
}
40+
const newCursorValue = textCursor.current + renderingLetterCount;
41+
const newText = text.substring(0, newCursorValue);
42+
textCursor.current += newText.length - textCursor.current;
43+
setStreamedMessageText(newText);
44+
}, streamingLetterIntervalMs);
45+
46+
return () => {
47+
clearInterval(interval);
48+
};
49+
}, [streamingLetterIntervalMs, renderingLetterCount, text]);
50+
51+
return { streamedMessageText };
52+
};

src/components/Message/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export * from './QuotedMessage';
1313
export * from './renderText';
1414
export * from './types';
1515
export * from './utils';
16+
export * from './StreamedMessageText';
1617
export type { TimestampProps } from './Timestamp';

src/components/Message/renderText/renderText.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export const defaultAllowedTagNames: Array<keyof JSX.IntrinsicElements | 'emoji'
3232
'pre',
3333
'blockquote',
3434
'del',
35+
'table',
36+
'thead',
37+
'tbody',
38+
'th',
39+
'tr',
40+
'td',
41+
'tfoot',
3542
// custom types (tagNames)
3643
'emoji',
3744
'mention',

src/components/Message/utils.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -494,5 +494,6 @@ export const isMessageBounced = <
494494
export const isMessageEdited = <
495495
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
496496
>(
497-
message: Pick<StreamMessage<StreamChatGenerics>, 'message_text_updated_at'>,
498-
) => !!message.message_text_updated_at;
497+
message: Pick<StreamMessage<StreamChatGenerics>, 'message_text_updated_at'> &
498+
Partial<Pick<StreamMessage<StreamChatGenerics>, 'ai_generated'>>,
499+
) => !!message.message_text_updated_at && !message.ai_generated;

0 commit comments

Comments
 (0)