From db6ce57dc0ba149e278ea239d6f27e273208635e Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Mon, 10 Apr 2023 03:36:13 -0700 Subject: [PATCH] Long-awaited: support for multiple parallel conversations Works really well. Up to 10 chats where conversations can happen simultaneously. This large refactor already carries considerable benefits, as we can now re-roll any prompt, see who's typing where and removed limitations around context. This also lays the foundations for very advanced features, from windowing systems to parallel reasoning. --- README.md | 11 +- components/ApplicationBar.tsx | 98 +++++++----- components/Chat.tsx | 157 ++++++++++--------- components/ChatMessage.tsx | 20 ++- components/ChatMessageList.tsx | 52 +++---- components/Composer.tsx | 50 +++--- components/Pages.tsx | 226 +++++++++++++++++++++------- components/util/PurposeSelector.tsx | 25 ++- lib/store-chats.ts | 161 +++++++++----------- 9 files changed, 477 insertions(+), 323 deletions(-) diff --git a/README.md b/README.md index 52e0423dc..e33eb46d4 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,28 @@ Or click fork & run on Vercel ## Roadmap 🛣️ -🚨 **Apr'23 - Attention! We look for your input!** 🚨 +🚨 ** April 2023 - Attention! We look for your input!** 🚨 | Roadmap | RFC 📝 | Status | Description | |:---------------------|-----------------------------------------------------------|:------:|:-----------------------------------------------------------------------------------------------------------------| | Editable Purposes 🎭 | https://github.com/enricoros/nextjs-chatgpt-app/issues/35 | 💬 | In-app customization of 'Purposes', as many forks are created for that reason. | | Templates sharing 🌐 | https://github.com/enricoros/nextjs-chatgpt-app/issues/35 | 💬 | Community repository of Purposes/Systems - Vote with 👍 and usage. Where to store? Bring your own key? Moderate? | | Reasoning Systems 🧩 | https://github.com/enricoros/nextjs-chatgpt-app/issues/36 | 🤔 | ReAct, DEPS, Reflexion - shall we? | -| Your epic idea | | 💡 | [Create RFC](https://github.com/enricoros/nextjs-chatgpt-app/issues/new?labels=RFC&body=Describe+the+idea) ❗ | +| Your epic idea | | 💡 | [Create RFC](https://github.com/enricoros/nextjs-chatgpt-app/issues/new?labels=RFC&body=Describe+the+idea) ❗ | ## Features ✨ 🚨 **We added cool new features to the app!** (bare-bones was [466a36](https://github.com/enricoros/nextjs-chatgpt-app/tree/466a3667a48060d406d60943af01fe26366563fb)) +- [x] _NEW 04.10_ 🎉 **Multiple chats** 📝📝📝 +- [x] _NEW 04.09_ 🎉 **Microphone improvements** 🎙️ +- [x] _NEW 04.08_ 🎉 **Precise Token counter** 📊 extra-useful +- [x] _NEW 04.08_ 🎉 Organization ID for OpenAI users +- [x] _NEW 04.07_ 🎉 **Pixel-perfect Markdown** 🎨 - [x] _NEW 04.04_ 🎉 **Download JSON** to export/backup chats 📥 - [x] _NEW 04.03_ 🎉 **PDF import** 📄🔀🧠 (fredliubojin) <- "ask questions to a PDF!" 🤯 -- [x] _NEW 04.03_ 🎉 **Tokens utilization** 📊 [WIP] +- [x] _NEW 04.03_ 🎉 **Tokens utilization** 📊 [Initial - just new messages, not full chat]

Token Counters

- [x] _NEW 04.02_ 🎉 **Markdown rendering** 🎨 (nilshulth) [WIP] - [x] 🎉 **NEW 04.01** Typing Avatars diff --git a/components/ApplicationBar.tsx b/components/ApplicationBar.tsx index 3b0f2c665..bc743139f 100644 --- a/components/ApplicationBar.tsx +++ b/components/ApplicationBar.tsx @@ -3,8 +3,8 @@ import { shallow } from 'zustand/shallow'; import { IconButton, ListDivider, ListItemDecorator, Menu, MenuItem, Sheet, Stack, Switch, useColorScheme } from '@mui/joy'; import { SxProps } from '@mui/joy/styles/types'; +import ClearIcon from '@mui/icons-material/Clear'; import DarkModeIcon from '@mui/icons-material/DarkMode'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import MenuIcon from '@mui/icons-material/Menu'; @@ -14,43 +14,40 @@ import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest'; import SwapVertIcon from '@mui/icons-material/SwapVert'; import { ChatModelId, ChatModels, SystemPurposeId, SystemPurposes } from '@/lib/data'; +import { ConfirmationModal } from '@/components/dialogs/ConfirmationModal'; import { PagesMenu } from '@/components/Pages'; import { StyledDropdown } from '@/components/util/StyledDropdown'; -import { useActiveConfiguration } from '@/lib/store-chats'; +import { useChatStore } from '@/lib/store-chats'; import { useSettingsStore } from '@/lib/store-settings'; /** * The top bar of the application, with the model and purpose selection, and menu/settings icons */ -export function ApplicationBar({ onClearConversation, onDownloadConversationJSON, onPublishConversation, onShowSettings, sx }: { - onClearConversation: (conversationId: (string | null)) => void; - onDownloadConversationJSON: (conversationId: (string | null)) => void; - onPublishConversation: (conversationId: (string | null)) => void; +export function ApplicationBar(props: { + conversationId: string | null; + onDownloadConversationJSON: (conversationId: string) => void; + onPublishConversation: (conversationId: string) => void; onShowSettings: () => void; sx?: SxProps }) { // state + const [clearConfirmationId, setClearConfirmationId] = React.useState(null); const [pagesMenuAnchor, setPagesMenuAnchor] = React.useState(null); const [actionsMenuAnchor, setActionsMenuAnchor] = React.useState(null); - // external state + + // settings + const { mode: colorMode, setMode: setColorMode } = useColorScheme(); + const { freeScroll, setFreeScroll, showSystemMessages, setShowSystemMessages } = useSettingsStore(state => ({ freeScroll: state.freeScroll, setFreeScroll: state.setFreeScroll, showSystemMessages: state.showSystemMessages, setShowSystemMessages: state.setShowSystemMessages, }), shallow); - const { chatModelId, setChatModelId, setSystemPurposeId, systemPurposeId } = useActiveConfiguration(); - - - const handleChatModelChange = (event: any, value: ChatModelId | null) => value && setChatModelId(value); - - const handleSystemPurposeChange = (event: any, value: SystemPurposeId | null) => value && setSystemPurposeId(value); - const closePagesMenu = () => setPagesMenuAnchor(null); - const closeActionsMenu = () => setActionsMenuAnchor(null); const handleDarkModeToggle = () => setColorMode(colorMode === 'dark' ? 'light' : 'dark'); @@ -61,25 +58,53 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON const handleActionShowSettings = (e: React.MouseEvent) => { e.stopPropagation(); - onShowSettings(); + props.onShowSettings(); closeActionsMenu(); }; - const handleActionDownloadChatJson = (e: React.MouseEvent) => { + + // conversation actions + + const { isEmpty, chatModelId, systemPurposeId, setMessages, setChatModelId, setSystemPurposeId } = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === props.conversationId); + return { + isEmpty: conversation ? !conversation.messages.length : true, + chatModelId: conversation ? conversation.chatModelId : null, + systemPurposeId: conversation ? conversation.systemPurposeId : null, + setMessages: state.setMessages, + setChatModelId: state.setChatModelId, + setSystemPurposeId: state.setSystemPurposeId, + }; + }, shallow); + + const handleConversationClear = (e: React.MouseEvent) => { e.stopPropagation(); - onDownloadConversationJSON(null); + setClearConfirmationId(props.conversationId); }; - const handleActionPublishChat = (e: React.MouseEvent) => { - e.stopPropagation(); - onPublishConversation(null); + const handleConfirmedClearConversation = () => { + if (clearConfirmationId) { + setMessages(clearConfirmationId, []); + setClearConfirmationId(null); + } }; - const handleActionClearConversation = (e: React.MouseEvent, id: string | null) => { + const handleConversationPublish = (e: React.MouseEvent) => { e.stopPropagation(); - onClearConversation(id || null); + props.conversationId && props.onPublishConversation(props.conversationId); }; + const handleConversationDownload = (e: React.MouseEvent) => { + e.stopPropagation(); + props.conversationId && props.onDownloadConversationJSON(props.conversationId); + }; + + const handleChatModelChange = (event: any, value: ChatModelId | null) => + value && props.conversationId && setChatModelId(props.conversationId, value); + + const handleSystemPurposeChange = (event: any, value: SystemPurposeId | null) => + value && props.conversationId && setSystemPurposeId(props.conversationId, value); + return <> @@ -89,7 +114,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON sx={{ p: 1, display: 'flex', flexDirection: 'row', justifyContent: 'space-between', - ...(sx || {}), + ...(props.sx || {}), }}> setPagesMenuAnchor(event.currentTarget)}> @@ -98,9 +123,9 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON - + {chatModelId && } - + {systemPurposeId && } @@ -111,11 +136,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON {/* Left menu */} - {} + {} {/* Right menu */} @@ -149,7 +170,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON - + {/**/} @@ -158,7 +179,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON Download JSON - + {/**/} @@ -169,11 +190,18 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON - handleActionClearConversation(e, null)}> - + + Clear conversation + + {/* Confirmations */} + setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation} + confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'} + /> + ; } \ No newline at end of file diff --git a/components/Chat.tsx b/components/Chat.tsx index a502bdec8..9aa3aacfb 100644 --- a/components/Chat.tsx +++ b/components/Chat.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'zustand/shallow'; import { Box, useTheme } from '@mui/joy'; import { SxProps } from '@mui/joy/styles/types'; @@ -6,106 +7,109 @@ import { SxProps } from '@mui/joy/styles/types'; import { ApiPublishResponse } from '../pages/api/publish'; import { ApplicationBar } from '@/components/ApplicationBar'; import { ChatMessageList } from '@/components/ChatMessageList'; +import { ChatModelId, SystemPurposeId, SystemPurposes } from '@/lib/data'; import { Composer } from '@/components/Composer'; import { ConfirmationModal } from '@/components/dialogs/ConfirmationModal'; import { Link } from '@/components/util/Link'; import { PublishedModal } from '@/components/dialogs/PublishedModal'; -import { SystemPurposes } from '@/lib/data'; -import { createDMessage, DMessage, downloadConversationJson, useActiveConfiguration, useChatStore } from '@/lib/store-chats'; +import { createDMessage, DMessage, downloadConversationJson, useChatStore } from '@/lib/store-chats'; import { publishConversation } from '@/lib/publish'; import { streamAssistantMessageEdits } from '@/lib/ai'; import { useSettingsStore } from '@/lib/store-settings'; +/** + * The main "chat" function. TODO: this is here so we can soon move it to the data model. + */ +const runAssistantUpdatingState = async (conversationId: string, history: DMessage[], assistantModel: ChatModelId, assistantPurpose: SystemPurposeId) => { + + // reference the state editing functions + const { startTyping, appendMessage, editMessage, setMessages } = useChatStore.getState(); + + // update the purpose of the system message (if not manually edited), and create if needed + { + const systemMessageIndex = history.findIndex(m => m.role === 'system'); + const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', ''); + + if (!systemMessage.updated) { + systemMessage.purposeId = assistantPurpose; + systemMessage.text = SystemPurposes[assistantPurpose]?.systemMessage + .replaceAll('{{Today}}', new Date().toISOString().split('T')[0]); + } + + history.unshift(systemMessage); + setMessages(conversationId, history); + } + + // create a blank and 'typing' message for the assistant + let assistantMessageId: string; + { + const assistantMessage: DMessage = createDMessage('assistant', '...'); + assistantMessage.typing = true; + assistantMessage.purposeId = history[0].purposeId; + assistantMessage.originLLM = assistantModel; + appendMessage(conversationId, assistantMessage); + assistantMessageId = assistantMessage.id; + } + + // when an abort controller is set, the UI switches to the "stop" mode + const controller = new AbortController(); + startTyping(conversationId, controller); + + const { apiKey, apiHost, apiOrganizationId, modelTemperature, modelMaxResponseTokens } = useSettingsStore.getState(); + await streamAssistantMessageEdits(conversationId, assistantMessageId, history, apiKey, apiHost, apiOrganizationId, assistantModel, modelTemperature, modelMaxResponseTokens, editMessage, controller.signal); + + // clear to send, again + startTyping(conversationId, null); +}; + + export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) { // state - const [clearConfirmationId, setClearConfirmationId] = React.useState(null); const [publishConversationId, setPublishConversationId] = React.useState(null); const [publishResponse, setPublishResponse] = React.useState(null); // external state const theme = useTheme(); - const { assistantTyping, conversationId: activeConversationId, chatModelId, systemPurposeId } = useActiveConfiguration(); + const { activeConversationId, chatModelId, systemPurposeId } = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === state.activeConversationId); + return { + activeConversationId: state.activeConversationId, + chatModelId: conversation?.chatModelId ?? null, + systemPurposeId: conversation?.systemPurposeId ?? null, + }; + }, shallow); - const runAssistant = async (conversationId: string, history: DMessage[]) => { - // reference the state editing functions - const { startTyping, appendMessage, editMessage, setMessages } = useChatStore.getState(); + const _findConversation = (conversationId: string) => + conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) ?? null : null; - // update the purpose of the system message (if not manually edited), and create if needed - { - const systemMessageIndex = history.findIndex(m => m.role === 'system'); - const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', ''); - - if (!systemMessage.updated) { - systemMessage.purposeId = systemPurposeId; - systemMessage.text = SystemPurposes[systemPurposeId]?.systemMessage - .replaceAll('{{Today}}', new Date().toISOString().split('T')[0]); - } - - history.unshift(systemMessage); - setMessages(conversationId, history); - } - - // create a blank and 'typing' message for the assistant - let assistantMessageId: string; - { - const assistantMessage: DMessage = createDMessage('assistant', '...'); - assistantMessage.typing = true; - assistantMessage.purposeId = history[0].purposeId; - assistantMessage.originLLM = chatModelId; - appendMessage(conversationId, assistantMessage); - assistantMessageId = assistantMessage.id; - } - - // when an abort controller is set, the UI switches to the "stop" mode - const controller = new AbortController(); - startTyping(conversationId, controller); - - const { apiKey, apiHost, apiOrganizationId, modelTemperature, modelMaxResponseTokens } = useSettingsStore.getState(); - await streamAssistantMessageEdits(conversationId, assistantMessageId, history, apiKey, apiHost, apiOrganizationId, chatModelId, modelTemperature, modelMaxResponseTokens, editMessage, controller.signal); - - // clear to send, again - startTyping(conversationId, null); - }; - - const findConversation = (conversationId: string) => - (conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) : null) ?? null; const handleSendMessage = async (conversationId: string, userText: string) => { - const conversation = findConversation(conversationId); - if (conversation) - await runAssistant(conversation.id, [...conversation.messages, createDMessage('user', userText)]); + const conversation = _findConversation(conversationId); + if (conversation && chatModelId && systemPurposeId) + await runAssistantUpdatingState(conversation.id, [...conversation.messages, createDMessage('user', userText)], chatModelId, systemPurposeId); }; - const handleDownloadConversationToJson = (conversationId: string | null) => { - if (conversationId || activeConversationId) { - const conversation = findConversation(conversationId || activeConversationId); - if (conversation) - downloadConversationJson(conversation); - } + const handleRestartConversation = async (conversationId: string, history: DMessage[]) => { + if (conversationId && chatModelId && systemPurposeId) + await runAssistantUpdatingState(conversationId, history, chatModelId, systemPurposeId); }; - const handlePublishConversation = (conversationId: string | null) => - setPublishConversationId(conversationId || activeConversationId || null); + const handleDownloadConversationToJson = (conversationId: string) => { + const conversation = _findConversation(conversationId); + conversation && downloadConversationJson(conversation); + }; + + + const handlePublishConversation = (conversationId: string) => setPublishConversationId(conversationId); const handleConfirmedPublishConversation = async () => { if (publishConversationId) { - const conversation = findConversation(publishConversationId); + const conversation = _findConversation(publishConversationId); setPublishConversationId(null); - if (conversation) - setPublishResponse(await publishConversation('paste.gg', conversation, !useSettingsStore.getState().showSystemMessages)); - } - }; - - const handleClearConversation = (conversationId: string | null) => - setClearConfirmationId(conversationId || activeConversationId || null); - - const handleConfirmedClearConversation = () => { - if (clearConfirmationId) { - useChatStore.getState().setMessages(clearConfirmationId, []); - setClearConfirmationId(null); + conversation && setPublishResponse(await publishConversation('paste.gg', conversation, !useSettingsStore.getState().showSystemMessages)); } }; @@ -119,7 +123,7 @@ export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) { }}> void, sx?: SxProps }) { }} /> void, sx?: SxProps }) { void, sx?: SxProps }) { setPublishResponse(null)} response={publishResponse} /> )} - {/* Confirmation for Delete */} - setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation} - confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'} - /> - ); diff --git a/components/ChatMessage.tsx b/components/ChatMessage.tsx index 81cf3a75c..eb578e8ca 100644 --- a/components/ChatMessage.tsx +++ b/components/ChatMessage.tsx @@ -237,7 +237,7 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str * or collapsing long user messages. * */ -export function ChatMessage(props: { message: DMessage, disableSend: boolean, lastMessage: boolean, onDelete: () => void, onEdit: (text: string) => void, onRunAgain: () => void }) { +export function ChatMessage(props: { message: DMessage, isLast: boolean, onMessageDelete: () => void, onMessageEdit: (text: string) => void, onMessageRunFrom: () => void }) { const { text: messageText, sender: messageSender, @@ -283,11 +283,9 @@ export function ChatMessage(props: { message: DMessage, disableSend: boolean, la }; const handleMenuRunAgain = (e: React.MouseEvent) => { - if (!props.disableSend) { - props.onRunAgain(); - e.preventDefault(); - closeOperationsMenu(); - } + e.preventDefault(); + props.onMessageRunFrom(); + closeOperationsMenu(); }; @@ -298,14 +296,14 @@ export function ChatMessage(props: { message: DMessage, disableSend: boolean, la if (e.key === 'Enter' && !e.shiftKey && !e.altKey) { e.preventDefault(); setIsEditing(false); - props.onEdit(editedText); + props.onMessageEdit(editedText); } }; const handleEditBlur = () => { setIsEditing(false); if (editedText !== messageText && editedText?.trim()) - props.onEdit(editedText); + props.onMessageEdit(editedText); }; @@ -494,12 +492,12 @@ export function ChatMessage(props: { message: DMessage, disableSend: boolean, la {fromUser && ( - + - {props.lastMessage ? 'Run Again' : 'Restart From Here'} + {props.isLast ? 'Run Again' : 'Restart From Here'} )} - + Delete diff --git a/components/ChatMessageList.tsx b/components/ChatMessageList.tsx index 453dd0565..04e5a8eed 100644 --- a/components/ChatMessageList.tsx +++ b/components/ChatMessageList.tsx @@ -6,22 +6,24 @@ import { SxProps } from '@mui/joy/styles/types'; import { ChatMessage } from '@/components/ChatMessage'; import { PurposeSelector } from '@/components/util/PurposeSelector'; -import { createDMessage, DMessage, useActiveConversation, useChatStore } from '@/lib/store-chats'; +import { createDMessage, DMessage, useChatStore } from '@/lib/store-chats'; import { useSettingsStore } from '@/lib/store-settings'; /** * A list of ChatMessages */ -export function ChatMessageList(props: { disableSend: boolean, sx?: SxProps, runAssistant: (conversationId: string, history: DMessage[]) => void }) { +export function ChatMessageList(props: { conversationId: string | null, onRestartConversation: (conversationId: string, history: DMessage[]) => void, sx?: SxProps }) { // state const messagesEndRef = React.useRef(null); // external state - const { id: activeConversationId, messages } = useActiveConversation(); - const { editMessage, deleteMessage } = useChatStore(state => ({ editMessage: state.editMessage, deleteMessage: state.deleteMessage }), shallow); const { freeScroll, showSystemMessages } = useSettingsStore(state => ({ freeScroll: state.freeScroll, showSystemMessages: state.showSystemMessages }), shallow); - + const { editMessage, deleteMessage } = useChatStore(state => ({ editMessage: state.editMessage, deleteMessage: state.deleteMessage }), shallow); + const messages = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === props.conversationId); + return conversation ? conversation.messages : []; + }, shallow); // when messages change, scroll to bottom (aka: at every new token) React.useEffect(() => { @@ -29,32 +31,31 @@ export function ChatMessageList(props: { disableSend: boolean, sx?: SxProps, run messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [freeScroll, messages]); - // hide system messages if the user chooses so - const filteredMessages = messages - .filter(m => m.role !== 'system' || showSystemMessages); - const handleMessageDelete = (messageId: string) => - deleteMessage(activeConversationId, messageId); + props.conversationId && deleteMessage(props.conversationId, messageId); const handleMessageEdit = (messageId: string, newText: string) => - editMessage(activeConversationId, messageId, { text: newText }, true); + props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true); - const handleMessageRunAgain = (messageId: string) => { + const handleRunFromMessage = (messageId: string) => { const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1); - props.runAssistant(activeConversationId, truncatedHistory); + props.conversationId && props.onRestartConversation(props.conversationId, truncatedHistory); }; - const handleRunExample = (example: string) => - props.runAssistant(activeConversationId, [...messages, createDMessage('user', example)]); + const handleRunPurposeExample = (text: string) => + props.conversationId && props.onRestartConversation(props.conversationId, [...messages, createDMessage('user', text)]); + + + // hide system messages if the user chooses so + const filteredMessages = messages.filter(m => m.role !== 'system' || showSystemMessages); // when there are no messages, show the purpose selector - if (!filteredMessages.length) return ( - - - - ); - + if (!filteredMessages.length) + return !props.conversationId ? null + : + + ; return ( @@ -64,11 +65,10 @@ export function ChatMessageList(props: { disableSend: boolean, sx?: SxProps, run handleMessageDelete(message.id)} - onEdit={newText => handleMessageEdit(message.id, newText)} - onRunAgain={() => handleMessageRunAgain(message.id)} />, + isLast={idx === filteredMessages.length - 1} + onMessageDelete={() => handleMessageDelete(message.id)} + onMessageEdit={newText => handleMessageEdit(message.id, newText)} + onMessageRunFrom={() => handleRunFromMessage(message.id)} />, )}
diff --git a/components/Composer.tsx b/components/Composer.tsx index d8c3b01e2..8f7aac5a2 100644 --- a/components/Composer.tsx +++ b/components/Composer.tsx @@ -20,7 +20,7 @@ import { TokenBadge } from '@/components/util/TokenBadge'; import { convertHTMLTableToMarkdown } from '@/lib/markdown'; import { countModelTokens } from '@/lib/tokens'; import { extractPdfText } from '@/lib/pdf'; -import { useChatStore, useConversationPartial } from '@/lib/store-chats'; +import { useChatStore } from '@/lib/store-chats'; import { useComposerStore, useSettingsStore } from '@/lib/store-settings'; import { useSpeechRecognition } from '@/components/util/useSpeechRecognition'; @@ -91,9 +91,9 @@ const pasteClipboardLegend = * @param {() => void} props.stopGeneration - Function to stop response generation */ export function Composer(props: { - conversationId: string; messageId: string | null; - sendMessage: (conversationId: string, text: string) => void; + conversationId: string | null; messageId: string | null; isDeveloperMode: boolean; + onSendMessage: (conversationId: string, text: string) => void; sx?: SxProps; }) { // state @@ -107,14 +107,24 @@ export function Composer(props: { // external state const theme = useTheme(); const { history, appendMessageToHistory } = useComposerStore(state => ({ history: state.history, appendMessageToHistory: state.appendMessageToHistory }), shallow); - const { assistantTyping, chatModelId, tokenCount: conversationTokenCount } = useConversationPartial(props.conversationId); const stopTyping = useChatStore(state => state.stopTyping); const modelMaxResponseTokens = useSettingsStore(state => state.modelMaxResponseTokens); + + const { assistantTyping, chatModelId, tokenCount: conversationTokenCount } = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === props.conversationId); + return { + assistantTyping: conversation ? !!conversation.abortController : false, + chatModelId: conversation ? conversation.chatModelId : null, + tokenCount: conversation ? conversation.tokenCount : 0, + }; + }, shallow); + + // derived state - const tokenLimit = ChatModels[chatModelId]?.contextWindowSize || 8192; + const tokenLimit = chatModelId ? ChatModels[chatModelId]?.contextWindowSize || 8192 : 0; const directTokens = React.useMemo(() => { - return !composeText ? 0 : countModelTokens(composeText, chatModelId, 'composer text'); + return (!composeText || !chatModelId) ? 0 : countModelTokens(composeText, chatModelId, 'composer text'); }, [chatModelId, composeText]); const indirectTokens = modelMaxResponseTokens + conversationTokenCount; const remainingTokens = tokenLimit - directTokens - indirectTokens; @@ -122,14 +132,14 @@ export function Composer(props: { const handleSendClicked = () => { const text = (composeText || '').trim(); - if (text.length) { + if (text.length && props.conversationId) { setComposeText(''); - props.sendMessage(props.conversationId, text); + props.onSendMessage(props.conversationId, text); appendMessageToHistory(text); } }; - const handleStopClicked = () => stopTyping(props.conversationId); + const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !e.altKey) { @@ -177,13 +187,15 @@ export function Composer(props: { } // see how we fare on budget - const newTextTokens = countModelTokens(newText, chatModelId, 'reducer trigger'); + if (chatModelId) { + const newTextTokens = countModelTokens(newText, chatModelId, 'reducer trigger'); - // simple trigger for the reduction dialog - if (newTextTokens > remainingTokens) { - setReducerTextTokens(newTextTokens); - setReducerText(newText); - return; + // simple trigger for the reduction dialog + if (newTextTokens > remainingTokens) { + setReducerTextTokens(newTextTokens); + setReducerText(newText); + return; + } } // update the text: just @@ -373,7 +385,7 @@ export function Composer(props: { lineHeight: 1.75, }} /> - + {!!tokenLimit && } }> + ? - : }
@@ -463,7 +475,7 @@ export function Composer(props: { )} {/* Content reducer modal */} - {reducerText?.length >= 1 && + {reducerText?.length >= 1 && chatModelId && void, + conversationDelete: (e: React.MouseEvent, conversationId: string) => void, + conversationEditTitle: (conversationId: string) => void, +}) { + + // bind to conversation + const conversation = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === props.conversationId); + return conversation && { + assistantTyping: !!conversation.abortController, + chatModelId: conversation.chatModelId, + name: conversation.name, + systemPurposeId: conversation.systemPurposeId, + }; + }, shallow); + + // sanity check: shouldn't happen, but just in case + if (!conversation) return null; + + const { assistantTyping, name, systemPurposeId } = conversation; + + const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓'; + + return ( + props.conversationActivate(props.conversationId)} + // sx={{ '&:hover > button': { opacity: 1 } }} + > + + {/* Icon */} + + {assistantTyping + ? ( + + ) : ( + + {/**/} + {textSymbol} + {/**/} + + )} + + + {/* Text */} + props.conversationEditTitle(props.conversationId)} sx={{ mr: 1 }}> + {DEBUG_CONVERSATION_IDs ? props.conversationId.slice(0, 10) : name}{assistantTyping && '...'} + + + {/* Edit */} + {/* props.onEditTitle(props.conversationId)}*/} + {/* sx={{*/} + {/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/} + {/* }}>*/} + {/* */} + {/*
*/} + + {/* Clear */} + {!props.isSingle && ( + props.conversationDelete(e, props.conversationId)}> + + + )} + + + ); +} /** * FIXME - TEMPORARY - placeholder for a proper Pages Drawer */ -export function PagesMenu(props: { pagesMenuAnchor: HTMLElement | null, onClose: () => void, onClearConversation: (e: React.MouseEvent, conversationId: string) => void }) { +export function PagesMenu(props: { pagesMenuAnchor: HTMLElement | null, onClose: () => void }) { + // state + const [deleteConfirmationId, setDeleteConfirmationId] = React.useState(null); // external state - const setActiveConversation = useChatStore(state => state.setActiveConversationId); - const conversationNames: { id: string; name: string, systemPurposeId: SystemPurposeId }[] = useConversationNames(); + const conversationIDs = useConversationIDs(); + const { activeConversationId, addConversation, deleteConversation, setActiveConversation } = useChatStore(state => ({ + activeConversationId: state.activeConversationId, + addConversation: state.addConversation, + deleteConversation: state.deleteConversation, + setActiveConversation: state.setActiveConversationId, + }), shallow); - const handleConversationClicked = (conversationId: string) => setActiveConversation(conversationId); - return + const singleChat = conversationIDs.length === 1; + const maxReached = conversationIDs.length >= MAX_CONVERSATIONS; - - - Active chats - - - {conversationNames.map((conversation) => ( - handleConversationClicked(conversation.id)} - > + const handleNew = () => addConversation(createDefaultConversation(), true); - - {SystemPurposes[conversation.systemPurposeId]?.symbol || ''} - + const handleConversationActivate = (conversationId: string) => setActiveConversation(conversationId); - - {conversation.name} + const handleConversationEditTitle = (conversationId: string) => console.log('edit title', conversationId); + + const handleConversationDelete = (e: React.MouseEvent, conversationId: string) => { + if (!singleChat) { + e.stopPropagation(); + setDeleteConfirmationId(conversationId); + } + }; + + const handleConfirmedDeleteConversation = () => { + if (!singleChat && deleteConfirmationId) { + deleteConversation(deleteConfirmationId); + setDeleteConfirmationId(null); + } + }; + + + const newSuffix = maxReached && ⚠️; + + return <> + + + + {/**/} + {/* */} + {/* Active chats*/} + {/* */} + {/**/} + + + + + New chat {newSuffix} - - props.onClearConversation(e, conversation.id)}> - - - - ))} - - - - New chat (soon) - {/* We need stable Chat and Message IDs, and one final review to the data structure of Conversation for future-proofing */} - - + {conversationIDs.map(conversationId => + )} + {/**/} + {/* */} + {/* Scratchpad*/} + {/* */} + {/**/} - - - Scratchpad - - + {/**/} + {/* */} + {/* */} + {/* Feature #17*/} + {/* */} + {/**/} - - - - Feature #17 - - + - ; + {/* Confirmations */} + setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation} + confirmationText={'Are you sure you want to delete this conversation?'} positiveActionText={'Delete conversation'} + /> + + ; } \ No newline at end of file diff --git a/components/util/PurposeSelector.tsx b/components/util/PurposeSelector.tsx index d23f15f44..221c7182f 100644 --- a/components/util/PurposeSelector.tsx +++ b/components/util/PurposeSelector.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'zustand/shallow'; import { Box, Button, Grid, IconButton, Input, Stack, Textarea, Typography, useTheme } from '@mui/joy'; import ClearIcon from '@mui/icons-material/Clear'; @@ -6,7 +7,7 @@ import SearchIcon from '@mui/icons-material/Search'; import TelegramIcon from '@mui/icons-material/Telegram'; import { SystemPurposeId, SystemPurposes } from '@/lib/data'; -import { useActiveConfiguration } from '@/lib/store-chats'; +import { useChatStore } from '@/lib/store-chats'; import { useSettingsStore } from '@/lib/store-settings'; @@ -33,7 +34,7 @@ const getRandomElement = (array: T[]): T | undefined => /** * Purpose selector for the current chat. Clicking on any item activates it for the current chat. */ -export function PurposeSelector(props: { onRunExample: (example: string) => void }) { +export function PurposeSelector(props: { conversationId: string, runExample: (example: string) => void }) { // state const [searchQuery, setSearchQuery] = React.useState(''); const [filteredIDs, setFilteredIDs] = React.useState(null); @@ -41,7 +42,17 @@ export function PurposeSelector(props: { onRunExample: (example: string) => void // external state const theme = useTheme(); const showPurposeFinder = useSettingsStore(state => state.showPurposeFinder); - const { systemPurposeId, setSystemPurposeId } = useActiveConfiguration(); + const { systemPurposeId, setSystemPurposeId } = useChatStore(state => { + const conversation = state.conversations.find(conversation => conversation.id === props.conversationId); + return { + systemPurposeId: conversation ? conversation.systemPurposeId : null, + setSystemPurposeId: conversation ? state.setSystemPurposeId : null, + }; + }, shallow); + + // safety check - shouldn't happen + if (!systemPurposeId || !setSystemPurposeId) + return null; const handleSearchClear = () => { @@ -76,9 +87,9 @@ export function PurposeSelector(props: { onRunExample: (example: string) => void }; - const handlePurposeChanged = (purpose: SystemPurposeId | null) => { - if (purpose) - setSystemPurposeId(purpose); + const handlePurposeChanged = (purposeId: SystemPurposeId | null) => { + if (purposeId) + setSystemPurposeId(props.conversationId, purposeId); }; const handleCustomSystemMessageChange = (v: React.ChangeEvent): void => { @@ -158,7 +169,7 @@ export function PurposeSelector(props: { onRunExample: (example: string) => void : (selectedExample ? <> {selectedExample} props.onRunExample(selectedExample)} + onClick={() => props.runExample(selectedExample)} sx={{ opacity: 0, transition: 'opacity 0.3s' }}> diff --git a/lib/store-chats.ts b/lib/store-chats.ts index a5a41c3ca..ad4b2a9df 100644 --- a/lib/store-chats.ts +++ b/lib/store-chats.ts @@ -9,12 +9,14 @@ import { updateTokenCount } from '@/lib/tokens'; /// Conversations Store +export const MAX_CONVERSATIONS = 10; + export interface ChatStore { conversations: DConversation[]; activeConversationId: string | null; // store setters - addConversation: (conversation: DConversation) => void; + addConversation: (conversation: DConversation, activate: boolean) => void; deleteConversation: (conversationId: string) => void; setActiveConversationId: (conversationId: string) => void; @@ -32,6 +34,35 @@ export interface ChatStore { _editConversation: (conversationId: string, update: Partial | ((conversation: DConversation) => Partial)) => void; } + +/** + * Conversation, a list of messages between humans and bots + * Future: + * - draftUserMessage?: { text: string; attachments: any[] }; + * - isMuted: boolean; isArchived: boolean; isStarred: boolean; participants: string[]; + */ +export interface DConversation { + id: string; + name: string; + messages: DMessage[]; + systemPurposeId: SystemPurposeId; + chatModelId: ChatModelId; + userTitle?: string; + autoTitle?: string; + tokenCount: number; // f(messages, chatModelId) + created: number; // created timestamp + updated: number | null; // updated timestamp + // Not persisted, used while in-memory, or temporarily by the UI + abortController: AbortController | null; +} + +const createConversation = (id: string, name: string, systemPurposeId: SystemPurposeId, chatModelId: ChatModelId): DConversation => + ({ id, name, messages: [], systemPurposeId, chatModelId, tokenCount: 0, created: Date.now(), updated: Date.now(), abortController: null }); + +export const createDefaultConversation = () => + createConversation(uuidv4(), 'Conversation', defaultSystemPurposeId, defaultChatModelId); + + /** * Message, sent or received, by humans or bots * @@ -72,59 +103,51 @@ export const createDMessage = (role: DMessage['role'], text: string): DMessage = }); -/** - * Conversation, a list of messages between humans and bots - * Future: - * - draftUserMessage?: { text: string; attachments: any[] }; - * - isMuted: boolean; isArchived: boolean; isStarred: boolean; participants: string[]; - */ -export interface DConversation { - id: string; - name: string; - messages: DMessage[]; - systemPurposeId: SystemPurposeId; - chatModelId: ChatModelId; - userTitle?: string; - autoTitle?: string; - tokenCount: number; // f(messages, chatModelId) - created: number; // created timestamp - updated: number | null; // updated timestamp - // Not persisted, used while in-memory, or temporarily by the UI - abortController: AbortController | null; -} - -const createConversation = (id: string, name: string, systemPurposeId: SystemPurposeId, chatModelId: ChatModelId): DConversation => - ({ id, name, messages: [], systemPurposeId, chatModelId, tokenCount: 0, created: Date.now(), updated: Date.now(), abortController: null }); - -const defaultConversations: DConversation[] = [createConversation(uuidv4(), 'Conversation', defaultSystemPurposeId, defaultChatModelId)]; - -const errorConversation: DConversation = createConversation('error-missing', 'Missing Conversation', defaultSystemPurposeId, defaultChatModelId); - +const defaultConversations: DConversation[] = [createDefaultConversation()]; export const useChatStore = create()(devtools( persist( (set, get) => ({ + // default state conversations: defaultConversations, activeConversationId: defaultConversations[0].id, - addConversation: (conversation: DConversation) => + addConversation: (conversation: DConversation, activate: boolean) => set(state => ( { conversations: [ conversation, - ...state.conversations.slice(0, 19), + ...state.conversations.slice(0, MAX_CONVERSATIONS - 1), ], + ...(activate ? { activeConversationId: conversation.id } : {}), } )), deleteConversation: (conversationId: string) => - set(state => ( - { - conversations: state.conversations.filter((conversation: DConversation): boolean => conversation.id !== conversationId), - } - )), + set(state => { + + // abort any pending requests on this conversation + const cIndex = state.conversations.findIndex((conversation: DConversation): boolean => conversation.id === conversationId); + if (cIndex >= 0 && state.conversations[cIndex].id !== 'error-missing') + state.conversations[cIndex].abortController?.abort(); + + // remove from the list + const conversations = state.conversations.filter((conversation: DConversation): boolean => conversation.id !== conversationId); + + // update the active conversation to the next in list + let activeConversationId = undefined; + if (state.activeConversationId === conversationId && cIndex >= 0) + activeConversationId = conversations.length + ? conversations[cIndex < conversations.length ? cIndex : conversations.length - 1].id + : null; + + return { + conversations, + ...(activeConversationId !== undefined ? { activeConversationId } : {}), + }; + }), setActiveConversationId: (conversationId: string) => set({ activeConversationId: conversationId }), @@ -233,6 +256,10 @@ export const useChatStore = create()(devtools( }), { name: 'app-chats', + // version history: + // - 1: [2023-03-18] app launch, single chat + // - 2: [2023-04-10] multi-chat version - invalidating data to be sure + version: 2, // omit the transient property from the persisted state partialize: (state) => ({ @@ -243,11 +270,16 @@ export const useChatStore = create()(devtools( }), }), - // rehydrate the transient property onRehydrateStorage: () => (state) => { - if (state) + if (state) { + // if nothing is selected, select the first conversation + if (!state.activeConversationId && state.conversations.length) + state.activeConversationId = state.conversations[0].id; + + // rehydrate the transient property for (const conversation of (state.conversations || [])) conversation.abortController = null; + } }, }), { @@ -257,58 +289,9 @@ export const useChatStore = create()(devtools( ); -// WARNING: this will re-render at high frequency (e.g. token received in any message therein) -// only use this for UI that renders messages -export function useActiveConversation(): DConversation { - const activeConversationId = useChatStore(state => state.activeConversationId); - return useChatStore(state => state.conversations.find(conversation => conversation.id === activeConversationId) || errorConversation); -} - -export function useActiveConfiguration() { - const { assistantTyping, conversationId, chatModelId, setChatModelId, systemPurposeId, setSystemPurposeId, tokenCount } = useChatStore(state => { - const _activeConversationId = state.activeConversationId; - const conversation = state.conversations.find(conversation => conversation.id === _activeConversationId) || errorConversation; - return { - assistantTyping: !!conversation.abortController, - conversationId: conversation.id, - chatModelId: conversation.chatModelId, - setChatModelId: state.setChatModelId, - systemPurposeId: conversation.systemPurposeId, - setSystemPurposeId: state.setSystemPurposeId, - tokenCount: conversation.tokenCount, - }; - }, shallow); - - return { - assistantTyping, - conversationId, - chatModelId, - setChatModelId: (chatModelId: ChatModelId) => setChatModelId(conversationId, chatModelId), - systemPurposeId, - setSystemPurposeId: (systemPurposeId: SystemPurposeId) => setSystemPurposeId(conversationId, systemPurposeId), - tokenCount, - }; -} - -export function useConversationPartial(conversationId: string) { - return useChatStore(state => { - const conversation = state.conversations.find(conversation => conversation.id === conversationId); - if (!conversation) return { - assistantTyping: false, - chatModelId: 'error' as ChatModelId, - tokenCount: 0, - }; - return { - assistantTyping: !!conversation.abortController, - chatModelId: conversation.chatModelId, - tokenCount: conversation.tokenCount, - }; - }, shallow); -} - -export const useConversationNames = (): { id: string, name: string, systemPurposeId: SystemPurposeId }[] => +export const useConversationIDs = (): string[] => useChatStore( - state => state.conversations.map((conversation) => ({ id: conversation.id, name: conversation.name, systemPurposeId: conversation.systemPurposeId })), + state => state.conversations.map(conversation => conversation.id), shallow, );