import type { Message } from 'ai'; import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; import { useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; import { fileModificationsToHTML } from '~/utils/diff'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; import { sendAnalyticsEvent, AnalyticsTrackEvent, AnalyticsAction } from '~/lib/analytics'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', exit: 'animated fadeOutRight', }); const logger = createScopedLogger('Chat'); export function Chat() { renderLogger.trace('Chat'); const { ready, initialMessages, storeMessageHistory } = useChatHistory(); return ( <> {ready && } { return ( ); }} icon={({ type }) => { /** * @todo Handle more types if we need them. This may require extra color palettes. */ switch (type) { case 'success': { return
; } case 'error': { return
; } } return undefined; }} position="bottom-right" pauseOnFocusLoss transition={toastAnimation} /> ); } interface ChatProps { initialMessages: Message[]; storeMessageHistory: (messages: Message[]) => Promise; } export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => { useShortcuts(); const textareaRef = useRef(null); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [animationScope, animate] = useAnimate(); const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ api: '/api/chat', onError: (error) => { logger.error('Request failed\n\n', error); toast.error('There was an error processing your request'); }, onFinish: () => { logger.debug('Finished streaming'); }, initialMessages, }); const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); const { parsedMessages, parseMessages } = useMessageParser(); const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; useEffect(() => { chatStore.setKey('started', initialMessages.length > 0); }, []); useEffect(() => { parseMessages(messages, isLoading); if (messages.length > initialMessages.length) { storeMessageHistory(messages).catch((error) => toast.error(error.message)); } }, [messages, isLoading, parseMessages]); const scrollTextArea = () => { const textarea = textareaRef.current; if (textarea) { textarea.scrollTop = textarea.scrollHeight; } }; const abort = () => { stop(); chatStore.setKey('aborted', true); workbenchStore.abortAllActions(); }; useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = 'auto'; const scrollHeight = textarea.scrollHeight; textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; } }, [input, textareaRef]); const runAnimation = async () => { if (chatStarted) { return; } await Promise.all([ animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), ]); chatStore.setKey('started', true); setChatStarted(true); }; const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { const _input = messageInput || input; if (_input.length === 0 || isLoading) { return; } /** * @note (delm) Usually saving files shouldn't take long but it may take longer if there * many unsaved files. In that case we need to block user input and show an indicator * of some kind so the user is aware that something is happening. But I consider the * happy case to be no unsaved files and I would expect users to save their changes * before they send another message. */ await workbenchStore.saveAllFiles(); const fileModifications = workbenchStore.getFileModifcations(); chatStore.setKey('aborted', false); runAnimation(); if (fileModifications !== undefined) { const diff = fileModificationsToHTML(fileModifications); /** * If we have file modifications we append a new user message manually since we have to prefix * the user input with the file modifications and we don't want the new user input to appear * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to * manually reset the input and we'd have to manually pass in file attachments. However, those * aren't relevant here. */ append({ role: 'user', content: `${diff}\n\n${_input}` }); /** * After sending a new message we reset all modifications since the model * should now be aware of all the changes. */ workbenchStore.resetAllFileModifications(); } else { append({ role: 'user', content: _input }); } setInput(''); resetEnhancer(); textareaRef.current?.blur(); const event = messages.length === 0 ? AnalyticsTrackEvent.ChatCreated : AnalyticsTrackEvent.MessageSent; sendAnalyticsEvent({ action: AnalyticsAction.Track, payload: { event, properties: { message: _input, }, }, }); }; const [messageRef, scrollRef] = useSnapScroll(); return ( { if (message.role === 'user') { return message; } return { ...message, content: parsedMessages[i] || '', }; })} enhancePrompt={() => { enhancePrompt(input, (input) => { setInput(input); scrollTextArea(); }); }} /> ); });