add streaming text

This commit is contained in:
Nate Kelley 2025-01-28 10:04:54 -07:00
parent 20c31af9da
commit 975b8dd8d9
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
12 changed files with 223 additions and 29 deletions

View File

@ -1,19 +1,21 @@
import type { BusterChatMessage } from '@/api/buster_socket/chats';
import React from 'react';
import { ChatUserMessage } from './ChatUserMessage';
import { AnimatePresence, motion } from 'framer-motion';
import { ChatResponseMessages } from './ChatResponseMessages';
import { createStyles } from 'antd-style';
import type { IBusterChatMessage } from '@/context/Chats/interfaces';
export const ChatMessageBlock: React.FC<{ message: BusterChatMessage }> = React.memo(
export const ChatMessageBlock: React.FC<{ message: IBusterChatMessage }> = React.memo(
({ message }) => {
const { styles, cx } = useStyles();
const { request_message, response_messages, id } = message;
const { request_message, response_messages, id, isCompletedStream } = message;
return (
<div className={cx(styles.messageBlock, 'flex flex-col space-y-3.5 px-4 py-2')} id={id}>
<ChatUserMessage requestMessage={request_message} />
<ChatResponseMessages responseMessages={response_messages} />
<ChatResponseMessages
responseMessages={response_messages}
isCompletedStream={isCompletedStream}
/>
</div>
);
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import { ChatResponseMessageProps } from './ChatResponseMessages';
export const ChatResponseMessage_File: React.FC<ChatResponseMessageProps> = React.memo(
({ responseMessage }) => {
return <div>ChatResponseMessage_File</div>;
}
);
ChatResponseMessage_File.displayName = 'ChatResponseMessage_File';

View File

@ -0,0 +1,44 @@
import { BusterChatMessage_text } from '@/api/buster_socket/chats';
import React, { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { animationConfig } from './animationConfig';
import { ChatResponseMessageProps } from './ChatResponseMessages';
export const ChatResponseMessage_Text: React.FC<ChatResponseMessageProps> = React.memo(
({ responseMessage: responseMessageProp, isCompletedStream }) => {
const responseMessage = responseMessageProp as BusterChatMessage_text;
const [textChunks, setTextChunks] = useState<string[]>([]);
useEffect(() => {
if (responseMessage.message_chunk && !responseMessage.message) {
// Handle streaming chunks
setTextChunks((prevChunks) => [...prevChunks, responseMessage.message_chunk]);
} else if (responseMessage.message) {
// Handle complete message
const currentText = textChunks.join('');
if (responseMessage.message.startsWith(currentText)) {
const remainingText = responseMessage.message.slice(currentText.length);
if (remainingText) {
setTextChunks((prevChunks) => [...prevChunks, remainingText]);
}
} else {
// If there's a mismatch, just use the complete message
setTextChunks([responseMessage.message]);
}
}
}, [responseMessage?.message_chunk, responseMessage?.message]);
return (
<div>
{textChunks.map((chunk, index) => (
<AnimatePresence key={index} initial={!isCompletedStream}>
<motion.span {...animationConfig}>{chunk}</motion.span>
</AnimatePresence>
))}
</div>
);
}
);
ChatResponseMessage_Text.displayName = 'ChatResponseMessage_Text';

View File

@ -0,0 +1,11 @@
import { BusterChatMessageResponse } from '@/api/buster_socket/chats';
import React from 'react';
import { ChatResponseMessageProps } from './ChatResponseMessages';
export const ChatResponseMessage_Thought: React.FC<ChatResponseMessageProps> = React.memo(
({ responseMessage }) => {
return <div>ChatResponseMessage_Thought</div>;
}
);
ChatResponseMessage_Thought.displayName = 'ChatResponseMessage_Thought';

View File

@ -1,10 +1,88 @@
import React from 'react';
import type { BusterChatMessageResponse } from '@/api/buster_socket/chats';
import React, { useEffect, useRef, useState } from 'react';
import type { BusterChatMessage_text, BusterChatMessageResponse } from '@/api/buster_socket/chats';
import { MessageContainer } from '../MessageContainer';
import { ChatResponseMessage_File } from './ChatResponseMessage_File';
import { ChatResponseMessage_Text } from './ChatResponseMessage_Text';
import { ChatResponseMessage_Thought } from './ChatResponseMessage_Thought';
import { useHotkeys } from 'react-hotkeys-hook';
import { faker } from '@faker-js/faker';
export const ChatResponseMessages: React.FC<{ responseMessages: BusterChatMessageResponse[] }> =
React.memo(({ responseMessages }) => {
return <MessageContainer senderName="">ChatResponseMessages</MessageContainer>;
});
export interface ChatResponseMessageProps {
responseMessage: BusterChatMessageResponse;
isCompletedStream: boolean;
}
const ChatResponseMessageRecord: Record<
BusterChatMessageResponse['type'],
React.FC<ChatResponseMessageProps>
> = {
text: ChatResponseMessage_Text,
file: ChatResponseMessage_File,
thought: ChatResponseMessage_Thought
};
interface ChatResponseMessagesProps {
responseMessages: BusterChatMessageResponse[];
isCompletedStream: boolean;
}
export const ChatResponseMessages: React.FC<ChatResponseMessagesProps> = React.memo(
({ responseMessages, isCompletedStream }) => {
// const [testMessages, setMessages] = useState<BusterChatMessageResponse[]>(responseMessages);
// const replicaOfMessages = useRef<string>('');
// useEffect(() => {
// setMessages(responseMessages);
// replicaOfMessages.current =
// (responseMessages as BusterChatMessage_text[])[0]?.message ||
// (responseMessages as BusterChatMessage_text[])[0]?.message_chunk ||
// '';
// }, [responseMessages]);
// const firstMessageId = testMessages[0]?.id;
// useHotkeys('x', () => {
// const threeRandomWords = ' ' + faker.lorem.words(6) + ' swag';
// setMessages((prevMessages) => {
// return prevMessages.map((message) => {
// if (message.id === firstMessageId) {
// replicaOfMessages.current = replicaOfMessages.current + threeRandomWords;
// return {
// ...message,
// message_chunk: threeRandomWords
// };
// }
// return message;
// });
// });
// });
// useHotkeys('z', () => {
// setMessages((prevMessages) => {
// return prevMessages.map((message) => {
// if (message.id === firstMessageId) {
// return { ...message, message: replicaOfMessages.current };
// }
// return message;
// });
// });
// });
return (
<MessageContainer>
{responseMessages.map((responseMessage) => {
const ChatResponseMessage = ChatResponseMessageRecord[responseMessage.type];
return (
<ChatResponseMessage
key={responseMessage.id}
responseMessage={responseMessage}
isCompletedStream={isCompletedStream}
/>
);
})}
</MessageContainer>
);
}
);
ChatResponseMessages.displayName = 'ChatResponseMessages';

View File

@ -0,0 +1,6 @@
export const animationConfig = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.675 }
};

View File

@ -1,6 +1,6 @@
'use client';
import React, { useMemo, useRef, useState } from 'react';
import React, { useRef } from 'react';
import { AppSplitter, AppSplitterRef } from '@/components/layout/AppSplitter';
import { ChatContainer } from './ChatContainer';
import { FileContainer } from './FileContainer';

View File

@ -5,9 +5,12 @@ import {
useContextSelector
} from '@fluentui/react-context-selector';
import { useBusterWebSocket } from '../BusterWebSocket';
import type { BusterChatAsset, IBusterChat } from '@/api/buster_socket/chats';
import type { BusterChatAsset, BusterChat } from '@/api/buster_socket/chats';
import { useMemoizedFn, useUnmount } from 'ahooks';
import type { FileType } from '@/api/buster_socket/chats';
import { MOCK_CHAT } from './MOCK_CHAT';
import { IBusterChat } from './interfaces';
import { chatUpgrader } from './helpers';
export const useBusterChat = () => {
const busterSocket = useBusterWebSocket();
@ -20,12 +23,13 @@ export const useBusterChat = () => {
// LISTENERS
const _onGetChat = useMemoizedFn((chat: IBusterChat) => {
chatsRef.current[chat.id] = chat;
const _onGetChat = useMemoizedFn((chat: BusterChat): IBusterChat => {
const upgradedChat = chatUpgrader(chat);
chatsRef.current[chat.id] = upgradedChat;
startTransition(() => {
//just used to trigger UI update
});
return chat;
return upgradedChat;
});
const _onGetChatAsset = useMemoizedFn((asset: BusterChatAsset) => {
@ -46,18 +50,17 @@ export const useBusterChat = () => {
});
const subscribeToChat = useMemoizedFn(({ chatId }: { chatId: string }) => {
return busterSocket.emitAndOnce({
emitEvent: {
route: '/chats/get',
payload: {
id: chatId
}
},
responseEvent: {
route: '/chats/get:getChat',
callback: _onGetChat
}
});
_onGetChat(MOCK_CHAT);
// return busterSocket.emitAndOnce({
// emitEvent: {
// route: '/chats/get',
// payload: { id: chatId }
// },
// responseEvent: {
// route: '/chats/get:getChat',
// callback: _onGetChat
// }
// });
});
const getChatAsset = useMemoizedFn(

View File

@ -21,7 +21,7 @@ const createMockUserMessage = (
const createMockResponseMessageText = (): BusterChatMessage_text => ({
id: faker.string.uuid(),
type: 'text',
message: faker.lorem.sentence(),
message: '',
message_chunk: faker.lorem.sentence()
});

View File

@ -0,0 +1,29 @@
import type { BusterChat, BusterChatMessage } from '@/api/buster_socket/chats';
import type { IBusterChat, IBusterChatMessage } from '../interfaces';
export const chatUpgrader = (
chat: BusterChat,
options?: { isNewChat?: boolean; isFollowupMessage?: boolean }
): IBusterChat => {
const { isNewChat = false, isFollowupMessage = false } = options || {};
return {
...chat,
isNewChat,
isFollowupMessage,
messages: chat.messages.map((message) =>
chatMessageUpgrader(message, { isCompletedStream: !isFollowupMessage && !isNewChat })
)
};
};
export const chatMessageUpgrader = (
message: BusterChatMessage,
options?: { isCompletedStream?: boolean }
): IBusterChatMessage => {
const { isCompletedStream = true } = options || {};
return {
...message,
isCompletedStream
};
};

View File

@ -0,0 +1 @@
export * from './chatUpgrader';

View File

@ -1 +1,11 @@
import type { BusterChat, BusterChatMessage } from '@/api/buster_socket/chats';
export interface IBusterChat extends BusterChat {
isNewChat: boolean;
isFollowupMessage: boolean;
messages: IBusterChatMessage[];
}
export interface IBusterChatMessage extends BusterChatMessage {
isCompletedStream: boolean;
}