Skip to content

Commit

Permalink
feat: add support for ai generated messages (#2570)
Browse files Browse the repository at this point in the history
  • Loading branch information
isekovanic authored Dec 4, 2024
1 parent 36c34cb commit fb1bfdd
Show file tree
Hide file tree
Showing 32 changed files with 342 additions and 46 deletions.
2 changes: 2 additions & 0 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
import {
AIStateIndicator,
Channel,
ChannelAvatar,
ChannelHeader,
Expand Down Expand Up @@ -88,6 +89,7 @@ const App = () => {
<Window>
<ChannelHeader Avatar={ChannelAvatar} />
<MessageList returnAllReadData />
<AIStateIndicator />
<MessageInput focus />
</Window>
<Thread virtualized />
Expand Down
1 change: 1 addition & 0 deletions examples/vite/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ body,
width: 0;
flex-shrink: 0;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
max-width: 1000px;

&--open {
width: 30%;
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"emoji-mart": "^5.4.0",
"react": "^18.0.0 || ^17.0.0 || ^16.8.0",
"react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0",
"stream-chat": "^8.45.0"
"stream-chat": "^8.46.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -187,7 +187,7 @@
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@stream-io/rollup-plugin-node-builtins": "^2.1.5",
"@stream-io/stream-chat-css": "^5.4.0",
"@stream-io/stream-chat-css": "^5.5.0",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.1.1",
"@testing-library/react-hooks": "^8.0.0",
Expand Down Expand Up @@ -257,7 +257,7 @@
"react-dom": "^18.1.0",
"react-test-renderer": "^18.1.0",
"semantic-release": "^19.0.5",
"stream-chat": "^8.45.0",
"stream-chat": "^8.46.0",
"ts-jest": "^29.1.4",
"typescript": "^5.4.5"
},
Expand Down
37 changes: 37 additions & 0 deletions src/components/AIStateIndicator/AIStateIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

import { Channel } from 'stream-chat';

import { AIStates, useAIState } from './hooks/useAIState';

import { useChannelStateContext, useTranslationContext } from '../../context';
import type { DefaultStreamChatGenerics } from '../../types/types';

export type AIStateIndicatorProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
channel?: Channel<StreamChatGenerics>;
};

export const AIStateIndicator = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>({
channel: channelFromProps,
}: AIStateIndicatorProps<StreamChatGenerics>) => {
const { t } = useTranslationContext();
const { channel: channelFromContext } = useChannelStateContext<StreamChatGenerics>(
'AIStateIndicator',
);
const channel = channelFromProps || channelFromContext;
const { aiState } = useAIState(channel);
const allowedStates = {
[AIStates.Thinking]: t('Thinking...'),
[AIStates.Generating]: t('Generating...'),
};

return aiState in allowedStates ? (
<div className='str-chat__ai-state-indicator-container'>
<p className='str-chat__ai-state-indicator-text'>{allowedStates[aiState]}</p>
</div>
) : null;
};
57 changes: 57 additions & 0 deletions src/components/AIStateIndicator/hooks/useAIState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';

import { AIState, Channel, Event } from 'stream-chat';

import type { DefaultStreamChatGenerics } from '../../../types/types';

export const AIStates = {
Error: 'AI_STATE_ERROR',
ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
Generating: 'AI_STATE_GENERATING',
Idle: 'AI_STATE_IDLE',
Thinking: 'AI_STATE_THINKING',
};

/**
* A hook that returns the current state of the AI.
* @param {Channel} channel - The channel for which we want to know the AI state.
* @returns {{ aiState: AIState }} The current AI state for the given channel.
*/
export const useAIState = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
channel?: Channel<StreamChatGenerics>,
): { aiState: AIState } => {
const [aiState, setAiState] = useState<AIState>(AIStates.Idle);

useEffect(() => {
if (!channel) {
return;
}

const indicatorChangedListener = channel.on(
'ai_indicator.update',
(event: Event<StreamChatGenerics>) => {
const { cid } = event;
const state = event.ai_state as AIState;
if (channel.cid === cid) {
setAiState(state);
}
},
);

const indicatorClearedListener = channel.on('ai_indicator.clear', (event) => {
const { cid } = event;
if (channel.cid === cid) {
setAiState(AIStates.Idle);
}
});

return () => {
indicatorChangedListener.unsubscribe();
indicatorClearedListener.unsubscribe();
};
}, [channel]);

return { aiState };
};
2 changes: 2 additions & 0 deletions src/components/AIStateIndicator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './AIStateIndicator';
export * from './hooks/useAIState';
6 changes: 6 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ type ChannelPropsForwardedToComponentContext<
| 'UnreadMessagesNotification'
| 'UnreadMessagesSeparator'
| 'VirtualMessage'
| 'StopAIGenerationButton'
| 'StreamedMessageText'
>;

const isUserResponseArray = <
Expand Down Expand Up @@ -1273,6 +1275,8 @@ const ChannelInner = <
ReactionsList: props.ReactionsList,
SendButton: props.SendButton,
StartRecordingAudioButton: props.StartRecordingAudioButton,
StopAIGenerationButton: props.StopAIGenerationButton,
StreamedMessageText: props.StreamedMessageText,
ThreadHead: props.ThreadHead,
ThreadHeader: props.ThreadHeader,
ThreadStart: props.ThreadStart,
Expand Down Expand Up @@ -1339,6 +1343,8 @@ const ChannelInner = <
props.UnreadMessagesNotification,
props.UnreadMessagesSeparator,
props.VirtualMessage,
props.StopAIGenerationButton,
props.StreamedMessageText,
props.emojiSearchIndex,
props.reactionOptions,
],
Expand Down
4 changes: 3 additions & 1 deletion src/components/ChannelPreview/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export const getLatestMessagePreview = <
}

if (previewTextToRender) {
return renderPreviewText(previewTextToRender);
return latestMessage.ai_generated
? previewTextToRender
: renderPreviewText(previewTextToRender);
}

if (latestMessage.command) {
Expand Down
8 changes: 7 additions & 1 deletion src/components/Message/MessageSimple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp';

import type { MessageUIComponentProps } from './types';
import type { DefaultStreamChatGenerics } from '../../types/types';
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';

type MessageSimpleWithContextProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
Expand Down Expand Up @@ -81,6 +82,7 @@ const MessageSimpleWithContext = <
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
ReactionsList = DefaultReactionList,
StreamedMessageText = DefaultStreamedMessageText,
PinIndicator,
} = useComponentContext<StreamChatGenerics>('MessageSimple');

Expand Down Expand Up @@ -185,7 +187,11 @@ const MessageSimpleWithContext = <
{message.attachments?.length && !message.quoted_message ? (
<Attachment actionHandler={handleAction} attachments={message.attachments} />
) : null}
<MessageText message={message} renderText={renderText} />
{message.ai_generated ? (
<StreamedMessageText message={message} renderText={renderText} />
) : (
<MessageText message={message} renderText={renderText} />
)}
{message.mml && (
<MML
actionHandler={handleAction}
Expand Down
40 changes: 40 additions & 0 deletions src/components/Message/StreamedMessageText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';

import { MessageText, MessageTextProps } from './MessageText';
import type { DefaultStreamChatGenerics } from '../../types/types';
import { useMessageContext } from '../../context';
import { useMessageTextStreaming } from './hooks';

export type StreamedMessageTextProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = Pick<MessageTextProps<StreamChatGenerics>, 'message' | 'renderText'> & {
renderingLetterCount?: number;
streamingLetterIntervalMs?: number;
};

export const StreamedMessageText = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
props: StreamedMessageTextProps<StreamChatGenerics>,
) => {
const {
message: messageFromProps,
renderingLetterCount,
renderText,
streamingLetterIntervalMs,
} = props;
const { message: messageFromContext } = useMessageContext<StreamChatGenerics>(
'StreamedMessageText',
);
const message = messageFromProps || messageFromContext;
const { text = '' } = message;
const { streamedMessageText } = useMessageTextStreaming({
renderingLetterCount,
streamingLetterIntervalMs,
text,
});

return (
<MessageText message={{ ...message, text: streamedMessageText }} renderText={renderText} />
);
};
1 change: 1 addition & 0 deletions src/components/Message/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './useRetryHandler';
export * from './useUserHandler';
export * from './useUserRole';
export * from './useReactionsFetcher';
export * from './useMessageTextStreaming';
52 changes: 52 additions & 0 deletions src/components/Message/hooks/useMessageTextStreaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';

import type { DefaultStreamChatGenerics } from '../../../types/types';
import type { StreamedMessageTextProps } from '../StreamedMessageText';

export type UseMessageTextStreamingProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = Pick<
StreamedMessageTextProps<StreamChatGenerics>,
'streamingLetterIntervalMs' | 'renderingLetterCount'
> & { text: string };

const DEFAULT_LETTER_INTERVAL = 30;
const DEFAULT_RENDERING_LETTER_COUNT = 2;

/**
* A hook that returns text in a streamed, typewriter fashion. The speed of streaming is
* configurable.
* @param {number} [streamingLetterIntervalMs=30] - The timeout between each typing animation in milliseconds.
* @param {number} [renderingLetterCount=2] - The number of letters to be rendered each time we update.
* @param {string} text - The text that we want to render in a typewriter fashion.
* @returns {{ streamedMessageText: string }} - A substring of the text property, up until we've finished rendering the typewriter animation.
*/
export const useMessageTextStreaming = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>({
streamingLetterIntervalMs = DEFAULT_LETTER_INTERVAL,
renderingLetterCount = DEFAULT_RENDERING_LETTER_COUNT,
text,
}: UseMessageTextStreamingProps<StreamChatGenerics>): { streamedMessageText: string } => {
const [streamedMessageText, setStreamedMessageText] = useState<string>(text);
const textCursor = useRef<number>(text.length);

useEffect(() => {
const textLength = text.length;
const interval = setInterval(() => {
if (!text || textCursor.current >= textLength) {
clearInterval(interval);
}
const newCursorValue = textCursor.current + renderingLetterCount;
const newText = text.substring(0, newCursorValue);
textCursor.current += newText.length - textCursor.current;
setStreamedMessageText(newText);
}, streamingLetterIntervalMs);

return () => {
clearInterval(interval);
};
}, [streamingLetterIntervalMs, renderingLetterCount, text]);

return { streamedMessageText };
};
1 change: 1 addition & 0 deletions src/components/Message/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export * from './QuotedMessage';
export * from './renderText';
export * from './types';
export * from './utils';
export * from './StreamedMessageText';
export type { TimestampProps } from './Timestamp';
7 changes: 7 additions & 0 deletions src/components/Message/renderText/renderText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export const defaultAllowedTagNames: Array<keyof JSX.IntrinsicElements | 'emoji'
'pre',
'blockquote',
'del',
'table',
'thead',
'tbody',
'th',
'tr',
'td',
'tfoot',
// custom types (tagNames)
'emoji',
'mention',
Expand Down
5 changes: 3 additions & 2 deletions src/components/Message/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -494,5 +494,6 @@ export const isMessageBounced = <
export const isMessageEdited = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
message: Pick<StreamMessage<StreamChatGenerics>, 'message_text_updated_at'>,
) => !!message.message_text_updated_at;
message: Pick<StreamMessage<StreamChatGenerics>, 'message_text_updated_at'> &
Partial<Pick<StreamMessage<StreamChatGenerics>, 'ai_generated'>>,
) => !!message.message_text_updated_at && !message.ai_generated;
Loading

0 comments on commit fb1bfdd

Please sign in to comment.