diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index b7792bfa0..1a33375d6 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -3,7 +3,7 @@ * Preventing TS checks with files presented in the video for a better presentation. */ import type { Message } from 'ai'; -import React, { type RefCallback, useEffect } from 'react'; +import React, { type RefCallback, useEffect, useState } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; import { Menu } from '~/components/sidebar/Menu.client'; import { IconButton } from '~/components/ui/IconButton'; @@ -12,12 +12,14 @@ import { classNames } from '~/utils/classNames'; import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants'; import { Messages } from './Messages.client'; import { SendButton } from './SendButton.client'; -import { useState } from 'react'; import { APIKeyManager } from './APIKeyManager'; import Cookies from 'js-cookie'; +import { toast } from 'react-toastify'; +import * as Tooltip from '@radix-ui/react-tooltip'; import styles from './BaseChat.module.scss'; import type { ProviderInfo } from '~/utils/types'; +import { ExportChatButton } from '~/components/chat/ExportChatButton'; const EXAMPLE_PROMPTS = [ { text: 'Build a todo app in React using Tailwind' }, @@ -79,6 +81,7 @@ interface BaseChatProps { chatStarted?: boolean; isStreaming?: boolean; messages?: Message[]; + description?: string; enhancingPrompt?: boolean; promptEnhanced?: boolean; input?: string; @@ -90,6 +93,8 @@ interface BaseChatProps { sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; + importChat?: (description: string, messages: Message[]) => Promise; + exportChat?: () => void; } export const BaseChat = React.forwardRef( @@ -113,6 +118,8 @@ export const BaseChat = React.forwardRef( handleInputChange, enhancePrompt, handleStop, + importChat, + exportChat, }, ref, ) => { @@ -161,7 +168,68 @@ export const BaseChat = React.forwardRef( } }; - return ( + const chatImportButton = !chatStarted && ( +
+ { + const file = e.target.files?.[0]; + + if (file && importChat) { + try { + const reader = new FileReader(); + + reader.onload = async (e) => { + try { + const content = e.target?.result as string; + const data = JSON.parse(content); + + if (!Array.isArray(data.messages)) { + toast.error('Invalid chat file format'); + } + + await importChat(data.description, data.messages); + toast.success('Chat imported successfully'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error('Failed to parse chat file: ' + error.message); + } else { + toast.error('Failed to parse chat file'); + } + } + }; + reader.onerror = () => toast.error('Failed to read chat file'); + reader.readAsText(file); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to import chat'); + } + e.target.value = ''; // Reset file input + } else { + toast.error('Something went wrong'); + } + }} + /> +
+
+ +
+
+
+ ); + + const baseChat = (
( )} + {chatStarted && {() => }}
{input.length > 3 ? (
@@ -309,6 +378,7 @@ export const BaseChat = React.forwardRef(
+ {chatImportButton} {!chatStarted && (
@@ -334,5 +404,7 @@ export const BaseChat = React.forwardRef(
); + + return {baseChat}; }, ); diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 47515ddb5..984182072 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -9,7 +9,7 @@ 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 { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; import { fileModificationsToHTML } from '~/utils/diff'; @@ -30,11 +30,20 @@ const logger = createScopedLogger('Chat'); export function Chat() { renderLogger.trace('Chat'); - const { ready, initialMessages, storeMessageHistory } = useChatHistory(); + const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory(); + const title = useStore(description); return ( <> - {ready && } + {ready && ( + + )} { return ( @@ -69,216 +78,224 @@ export function Chat() { interface ChatProps { initialMessages: Message[]; storeMessageHistory: (messages: Message[]) => Promise; + importChat: (description: string, messages: Message[]) => Promise; + exportChat: () => void; + description?: string; } -export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => { - useShortcuts(); - - const textareaRef = useRef(null); - - const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); - 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; - }); - - const { showChat } = useStore(chatStore); - - const [animationScope, animate] = useAnimate(); - - const [apiKeys, setApiKeys] = useState>({}); - - const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ - api: '/api/chat', - body: { - apiKeys, - }, - onError: (error) => { - logger.error('Request failed\n\n', error); - toast.error( - 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), - ); - }, - 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]); +export const ChatImpl = memo( + ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => { + useShortcuts(); + + const textareaRef = useRef(null); + + const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); + 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; + }); + + const { showChat } = useStore(chatStore); + + const [animationScope, animate] = useAnimate(); + + const [apiKeys, setApiKeys] = useState>({}); + + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ + api: '/api/chat', + body: { + apiKeys, + }, + onError: (error) => { + logger.error('Request failed\n\n', error); + toast.error( + 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'), + ); + }, + onFinish: () => { + logger.debug('Finished streaming'); + }, + initialMessages, + }); - const scrollTextArea = () => { - const textarea = textareaRef.current; + const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); + const { parsedMessages, parseMessages } = useMessageParser(); - if (textarea) { - textarea.scrollTop = textarea.scrollHeight; - } - }; + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; - const abort = () => { - stop(); - chatStore.setKey('aborted', true); - workbenchStore.abortAllActions(); - }; + useEffect(() => { + chatStore.setKey('started', initialMessages.length > 0); + }, []); - useEffect(() => { - const textarea = textareaRef.current; + useEffect(() => { + parseMessages(messages, isLoading); - if (textarea) { - textarea.style.height = 'auto'; + if (messages.length > initialMessages.length) { + storeMessageHistory(messages).catch((error) => toast.error(error.message)); + } + }, [messages, isLoading, parseMessages]); - const scrollHeight = textarea.scrollHeight; + const scrollTextArea = () => { + const textarea = textareaRef.current; - textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; - textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; - } - }, [input, textareaRef]); + if (textarea) { + textarea.scrollTop = textarea.scrollHeight; + } + }; - const runAnimation = async () => { - if (chatStarted) { - return; - } + const abort = () => { + stop(); + chatStore.setKey('aborted', true); + workbenchStore.abortAllActions(); + }; - await Promise.all([ - animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), - animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), - ]); + useEffect(() => { + const textarea = textareaRef.current; - chatStore.setKey('started', true); + if (textarea) { + textarea.style.height = 'auto'; - setChatStarted(true); - }; + const scrollHeight = textarea.scrollHeight; - const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { - const _input = messageInput || input; + textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; + textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, [input, textareaRef]); - if (_input.length === 0 || isLoading) { - return; - } + const runAnimation = async () => { + if (chatStarted) { + 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(); + await Promise.all([ + animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), + animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), + ]); - const fileModifications = workbenchStore.getFileModifcations(); + chatStore.setKey('started', true); - chatStore.setKey('aborted', false); + setChatStarted(true); + }; - runAnimation(); + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { + const _input = messageInput || input; - if (fileModifications !== undefined) { - const diff = fileModificationsToHTML(fileModifications); + if (_input.length === 0 || isLoading) { + return; + } /** - * 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. + * @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. */ - append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${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: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` }); - } - - setInput(''); - - resetEnhancer(); - - textareaRef.current?.blur(); - }; - - 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 }); - }; + 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: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${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: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` }); + } + + setInput(''); + + resetEnhancer(); + + textareaRef.current?.blur(); + }; + + 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 ( + { + if (message.role === 'user') { + return message; + } - return ( - { - if (message.role === 'user') { - return message; - } - - return { - ...message, - content: parsedMessages[i] || '', - }; - })} - enhancePrompt={() => { - enhancePrompt( - input, - (input) => { - setInput(input); - scrollTextArea(); - }, - model, - provider, - apiKeys, - ); - }} - /> - ); -}); + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + enhancePrompt={() => { + enhancePrompt( + input, + (input) => { + setInput(input); + scrollTextArea(); + }, + model, + provider, + apiKeys, + ); + }} + /> + ); + }, +); diff --git a/app/components/chat/ExportChatButton.tsx b/app/components/chat/ExportChatButton.tsx new file mode 100644 index 000000000..6ab294bc1 --- /dev/null +++ b/app/components/chat/ExportChatButton.tsx @@ -0,0 +1,13 @@ +import WithTooltip from '~/components/ui/Tooltip'; +import { IconButton } from '~/components/ui/IconButton'; +import React from 'react'; + +export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => { + return ( + + exportChat?.()}> +
+
+
+ ); +}; diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index a67104c95..4a2ac6ac0 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -3,11 +3,11 @@ import React from 'react'; import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; -import * as Tooltip from '@radix-ui/react-tooltip'; import { useLocation } from '@remix-run/react'; import { db, chatId } from '~/lib/persistence/useChatHistory'; import { forkChat } from '~/lib/persistence/db'; import { toast } from 'react-toastify'; +import WithTooltip from '~/components/ui/Tooltip'; interface MessagesProps { id?: string; @@ -41,92 +41,66 @@ export const Messages = React.forwardRef((props: }; return ( - -
- {messages.length > 0 - ? messages.map((message, index) => { - const { role, content, id: messageId } = message; - const isUserMessage = role === 'user'; - const isFirst = index === 0; - const isLast = index === messages.length - 1; +
+ {messages.length > 0 + ? messages.map((message, index) => { + const { role, content, id: messageId } = message; + const isUserMessage = role === 'user'; + const isFirst = index === 0; + const isLast = index === messages.length - 1; - return ( -
- {isUserMessage && ( -
-
-
- )} -
- {isUserMessage ? : } + return ( +
+ {isUserMessage && ( +
+
- {!isUserMessage && ( -
- - - {messageId && ( -
- )} -
- ); - }) - : null} - {isStreaming && ( -
- )} -
- + +
+ )} +
+ ); + }) + : null} + {isStreaming && ( +
+ )} +
); }); diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx index df270c8c3..3f58735f8 100644 --- a/app/components/sidebar/HistoryItem.tsx +++ b/app/components/sidebar/HistoryItem.tsx @@ -1,14 +1,16 @@ import * as Dialog from '@radix-ui/react-dialog'; import { useEffect, useRef, useState } from 'react'; import { type ChatHistoryItem } from '~/lib/persistence'; +import WithTooltip from '~/components/ui/Tooltip'; interface HistoryItemProps { item: ChatHistoryItem; onDelete?: (event: React.UIEvent) => void; onDuplicate?: (id: string) => void; + exportChat: (id?: string) => void; } -export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) { +export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) { const [hovering, setHovering] = useState(false); const hoverRef = useRef(null); @@ -43,25 +45,41 @@ export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) { > {item.description} -
+
{hovering && (
- {onDuplicate && ( -
)} diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index 2cb3b7756..5becf82c4 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -33,7 +33,7 @@ const menuVariants = { type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null; export function Menu() { - const { duplicateCurrentChat } = useChatHistory(); + const { duplicateCurrentChat, exportChat } = useChatHistory(); const menuRef = useRef(null); const [list, setList] = useState([]); const [open, setOpen] = useState(false); @@ -101,7 +101,6 @@ export function Menu() { const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => { event.preventDefault(); - setDialogContent({ type: 'delete', item }); }; @@ -142,6 +141,7 @@ export function Menu() { handleDeleteClick(event, item)} onDuplicate={() => handleDuplicate(item.id)} /> diff --git a/app/components/ui/Tooltip.tsx b/app/components/ui/Tooltip.tsx new file mode 100644 index 000000000..73ababb3a --- /dev/null +++ b/app/components/ui/Tooltip.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import type { ReactNode } from 'react'; + +interface ToolTipProps { + tooltip: string; + children: ReactNode | ReactNode[]; + sideOffset?: number; + className?: string; + arrowClassName?: string; + tooltipStyle?: any; //TODO better type +} + +const WithTooltip = ({ + tooltip, + children, + sideOffset = 5, + className = '', + arrowClassName = '', + tooltipStyle = {}, +}: ToolTipProps) => { + return ( + + {children} + + + {tooltip} + + + + + ); +}; + +export default WithTooltip; diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index b21ace006..6ce604d9a 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -176,14 +176,7 @@ export async function forkChat(db: IDBDatabase, chatId: string, messageId: strin // Get messages up to and including the selected message const messages = chat.messages.slice(0, messageIndex + 1); - // Generate new IDs - const newId = await getNextId(db); - const urlId = await getUrlId(db, newId); - - // Create the forked chat - await setMessages(db, newId, messages, urlId, chat.description ? `${chat.description} (fork)` : 'Forked chat'); - - return urlId; + return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages); } export async function duplicateChat(db: IDBDatabase, id: string): Promise { @@ -193,15 +186,23 @@ export async function duplicateChat(db: IDBDatabase, id: string): Promise { const newId = await getNextId(db); const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat await setMessages( db, newId, - chat.messages, + messages, newUrlId, // Use the new urlId - `${chat.description || 'Chat'} (copy)`, + description, ); return newUrlId; // Return the urlId instead of id for navigation diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index 62bd53a48..9daa61fdf 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -4,7 +4,15 @@ import { atom } from 'nanostores'; import type { Message } from 'ai'; import { toast } from 'react-toastify'; import { workbenchStore } from '~/lib/stores/workbench'; -import { getMessages, getNextId, getUrlId, openDatabase, setMessages, duplicateChat } from './db'; +import { + getMessages, + getNextId, + getUrlId, + openDatabase, + setMessages, + duplicateChat, + createChatFromMessages, +} from './db'; export interface ChatHistoryItem { id: string; @@ -113,6 +121,45 @@ export function useChatHistory() { console.log(error); } }, + importChat: async (description: string, messages: Message[]) => { + if (!db) { + return; + } + + try { + const newId = await createChatFromMessages(db, description, messages); + window.location.href = `/chat/${newId}`; + toast.success('Chat imported successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error('Failed to import chat: ' + error.message); + } else { + toast.error('Failed to import chat'); + } + } + }, + exportChat: async (id = urlId) => { + if (!db || !id) { + return; + } + + const chat = await getMessages(db, id); + const chatData = { + messages: chat.messages, + description: chat.description, + exportDate: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chat-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, }; } diff --git a/app/utils/logger.ts b/app/utils/logger.ts index 1a5c932c5..9b2c31c95 100644 --- a/app/utils/logger.ts +++ b/app/utils/logger.ts @@ -11,7 +11,7 @@ interface Logger { setLevel: (level: DebugLevel) => void; } -let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info'; +let currentLevel: DebugLevel = (import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV) ? 'debug' : 'info'; const isWorker = 'HTMLRewriter' in globalThis; const supportsColor = !isWorker;