/* * @ts-nocheck * Preventing TS checks with files presented in the video for a better presentation. */ import { useStore } from '@nanostores/react'; import type { Message } from 'ai'; import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; import Cookies from 'js-cookie'; import { debounce } from '~/utils/debounce'; import { useSettings } from '~/lib/hooks/useSettings'; import type { ProviderInfo } from '~/types/model'; import { useSearchParams } from '@remix-run/react'; import { createSampler } from '~/utils/sampler'; import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate'; import { logStore } from '~/lib/stores/logs'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', exit: 'animated fadeOutRight', }); const logger = createScopedLogger('Chat'); export function Chat() { renderLogger.trace('Chat'); const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory(); const title = useStore(description); useEffect(() => { workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id)); }, [initialMessages]); 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} /> ); } const processSampledMessages = createSampler( (options: { messages: Message[]; initialMessages: Message[]; isLoading: boolean; parseMessages: (messages: Message[], isLoading: boolean) => void; storeMessageHistory: (messages: Message[]) => Promise; }) => { const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options; parseMessages(messages, isLoading); if (messages.length > initialMessages.length) { storeMessageHistory(messages).catch((error) => toast.error(error.message)); } }, 50, ); interface ChatProps { initialMessages: Message[]; storeMessageHistory: (messages: Message[]) => Promise; importChat: (description: string, messages: Message[]) => Promise; exportChat: () => void; description?: string; } export const ChatImpl = memo( ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => { useShortcuts(); const textareaRef = useRef(null); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [uploadedFiles, setUploadedFiles] = useState([]); const [imageDataList, setImageDataList] = useState([]); const [searchParams, setSearchParams] = useSearchParams(); const [fakeLoading, setFakeLoading] = useState(false); const files = useStore(workbenchStore.files); const actionAlert = useStore(workbenchStore.alert); const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); return savedModel || DEFAULT_MODEL; }); const [provider, setProvider] = useState(() => { const savedProvider = Cookies.get('selectedProvider'); return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo; }); const { showChat } = useStore(chatStore); const [animationScope, animate] = useAnimate(); const [apiKeys, setApiKeys] = useState>({}); const { messages, isLoading, input, handleInputChange, setInput, stop, append, setMessages, reload, error, data: chatData, setData, } = useChat({ api: '/api/chat', body: { apiKeys, files, promptId, contextOptimization: contextOptimizationEnabled, }, sendExtraMessageFields: true, onError: (e) => { logger.error('Request failed\n\n', e, error); logStore.logError('Chat request failed', e, { component: 'Chat', action: 'request', error: e.message, }); toast.error( 'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'), ); }, onFinish: (message, response) => { const usage = response.usage; setData(undefined); if (usage) { console.log('Token usage:', usage); logStore.logProvider('Chat response completed', { component: 'Chat', action: 'response', model, provider: provider.name, usage, messageLength: message.content.length, }); } logger.debug('Finished streaming'); }, initialMessages, initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '', }); useEffect(() => { const prompt = searchParams.get('prompt'); // console.log(prompt, searchParams, model, provider); if (prompt) { setSearchParams({}); runAnimation(); append({ role: 'user', content: [ { type: 'text', text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`, }, ] as any, // Type assertion to bypass compiler check }); } }, [model, provider, searchParams]); 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(() => { processSampledMessages({ messages, initialMessages, isLoading, parseMessages, storeMessageHistory, }); }, [messages, isLoading, parseMessages]); const scrollTextArea = () => { const textarea = textareaRef.current; if (textarea) { textarea.scrollTop = textarea.scrollHeight; } }; const abort = () => { stop(); chatStore.setKey('aborted', true); workbenchStore.abortAllActions(); logStore.logProvider('Chat response aborted', { component: 'Chat', action: 'abort', model, provider: provider.name, }); }; 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 messageContent = messageInput || input; if (!messageContent?.trim()) { return; } if (isLoading) { abort(); return; } runAnimation(); if (!chatStarted) { setFakeLoading(true); if (autoSelectTemplate) { const { template, title } = await selectStarterTemplate({ message: messageContent, model, provider, }); if (template !== 'blank') { const temResp = await getTemplates(template, title).catch((e) => { if (e.message.includes('rate limit')) { toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template'); } else { toast.warning('Failed to import starter template\n Continuing with blank template'); } return null; }); if (temResp) { const { assistantMessage, userMessage } = temResp; setMessages([ { id: `${new Date().getTime()}`, role: 'user', content: messageContent, }, { id: `${new Date().getTime()}`, role: 'assistant', content: assistantMessage, }, { id: `${new Date().getTime()}`, role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`, annotations: ['hidden'], }, ]); reload(); setFakeLoading(false); return; } } } // If autoSelectTemplate is disabled or template selection failed, proceed with normal message setMessages([ { id: `${new Date().getTime()}`, role: 'user', content: [ { type: 'text', text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`, }, ...imageDataList.map((imageData) => ({ type: 'image', image: imageData, })), ] as any, }, ]); reload(); setFakeLoading(false); return; } if (error != null) { setMessages(messages.slice(0, -1)); } const fileModifications = workbenchStore.getFileModifcations(); chatStore.setKey('aborted', false); if (fileModifications !== undefined) { append({ role: 'user', content: [ { type: 'text', text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`, }, ...imageDataList.map((imageData) => ({ type: 'image', image: imageData, })), ] as any, }); workbenchStore.resetAllFileModifications(); } else { append({ role: 'user', content: [ { type: 'text', text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`, }, ...imageDataList.map((imageData) => ({ type: 'image', image: imageData, })), ] as any, }); } setInput(''); Cookies.remove(PROMPT_COOKIE_KEY); setUploadedFiles([]); setImageDataList([]); resetEnhancer(); textareaRef.current?.blur(); }; /** * Handles the change event for the textarea and updates the input state. * @param event - The change event from the textarea. */ const onTextareaChange = (event: React.ChangeEvent) => { handleInputChange(event); }; /** * Debounced function to cache the prompt in cookies. * Caches the trimmed value of the textarea input after a delay to optimize performance. */ const debouncedCachePrompt = useCallback( debounce((event: React.ChangeEvent) => { const trimmedValue = event.target.value.trim(); Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 }); }, 1000), [], ); const [messageRef, scrollRef] = useSnapScroll(); useEffect(() => { const storedApiKeys = Cookies.get('apiKeys'); if (storedApiKeys) { setApiKeys(JSON.parse(storedApiKeys)); } }, []); const handleModelChange = (newModel: string) => { setModel(newModel); Cookies.set('selectedModel', newModel, { expires: 30 }); }; const handleProviderChange = (newProvider: ProviderInfo) => { setProvider(newProvider); Cookies.set('selectedProvider', newProvider.name, { expires: 30 }); }; return ( { onTextareaChange(e); debouncedCachePrompt(e); }} handleStop={abort} description={description} importChat={importChat} exportChat={exportChat} messages={messages.map((message, i) => { if (message.role === 'user') { return message; } return { ...message, content: parsedMessages[i] || '', }; })} enhancePrompt={() => { enhancePrompt( input, (input) => { setInput(input); scrollTextArea(); }, model, provider, apiKeys, ); }} uploadedFiles={uploadedFiles} setUploadedFiles={setUploadedFiles} imageDataList={imageDataList} setImageDataList={setImageDataList} actionAlert={actionAlert} clearAlert={() => workbenchStore.clearAlert()} data={chatData} /> ); }, );