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.
This commit is contained in:
Enrico Ros
2023-04-10 03:36:13 -07:00
parent f37c5630ac
commit db6ce57dc0
9 changed files with 477 additions and 323 deletions
+8 -3
View File
@@ -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]
<p><a href="docs/feature_token_counter.png"><img src="docs/feature_token_counter.png" width='300' alt="Token Counters"/></a></p>
- [x] _NEW 04.02_ 🎉 **Markdown rendering** 🎨 (nilshulth) [WIP]
- [x] 🎉 **NEW 04.01** Typing Avatars
+63 -35
View File
@@ -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<string | null>(null);
const [pagesMenuAnchor, setPagesMenuAnchor] = React.useState<HTMLElement | null>(null);
const [actionsMenuAnchor, setActionsMenuAnchor] = React.useState<HTMLElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
e.stopPropagation();
onClearConversation(id || null);
props.conversationId && props.onPublishConversation(props.conversationId);
};
const handleConversationDownload = (e: React.MouseEvent<HTMLDivElement>) => {
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 || {}),
}}>
<IconButton variant='plain' onClick={event => setPagesMenuAnchor(event.currentTarget)}>
@@ -98,9 +123,9 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON
<Stack direction='row' sx={{ my: 'auto' }}>
<StyledDropdown items={ChatModels} value={chatModelId} onChange={handleChatModelChange} />
{chatModelId && <StyledDropdown items={ChatModels} value={chatModelId} onChange={handleChatModelChange} />}
<StyledDropdown items={SystemPurposes} value={systemPurposeId} onChange={handleSystemPurposeChange} />
{systemPurposeId && <StyledDropdown items={SystemPurposes} value={systemPurposeId} onChange={handleSystemPurposeChange} />}
</Stack>
@@ -111,11 +136,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON
{/* Left menu */}
{<PagesMenu
pagesMenuAnchor={pagesMenuAnchor}
onClose={closePagesMenu}
onClearConversation={handleActionClearConversation}
/>}
{<PagesMenu pagesMenuAnchor={pagesMenuAnchor} onClose={closePagesMenu} />}
{/* Right menu */}
@@ -149,7 +170,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON
<ListDivider />
<MenuItem onClick={handleActionDownloadChatJson}>
<MenuItem disabled={!props.conversationId || isEmpty} onClick={handleConversationDownload}>
<ListItemDecorator>
{/*<Badge size='sm' color='danger'>*/}
<FileDownloadIcon />
@@ -158,7 +179,7 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON
Download JSON
</MenuItem>
<MenuItem onClick={handleActionPublishChat}>
<MenuItem disabled={!props.conversationId || isEmpty} onClick={handleConversationPublish}>
<ListItemDecorator>
{/*<Badge size='sm' color='primary'>*/}
<ExitToAppIcon />
@@ -169,11 +190,18 @@ export function ApplicationBar({ onClearConversation, onDownloadConversationJSON
<ListDivider />
<MenuItem onClick={e => handleActionClearConversation(e, null)}>
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
<MenuItem disabled={!props.conversationId || isEmpty} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Clear conversation
</MenuItem>
</Menu>
{/* Confirmations */}
<ConfirmationModal
open={!!clearConfirmationId} onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'}
/>
</>;
}
+78 -79
View File
@@ -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<string | null>(null);
const [publishConversationId, setPublishConversationId] = React.useState<string | null>(null);
const [publishResponse, setPublishResponse] = React.useState<ApiPublishResponse | null>(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 }) {
}}>
<ApplicationBar
onClearConversation={handleClearConversation}
conversationId={activeConversationId}
onDownloadConversationJSON={handleDownloadConversationToJson}
onPublishConversation={handlePublishConversation}
onShowSettings={props.onShowSettings}
@@ -129,7 +133,8 @@ export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) {
}} />
<ChatMessageList
disableSend={assistantTyping} runAssistant={runAssistant}
conversationId={activeConversationId}
onRestartConversation={handleRestartConversation}
sx={{
flexGrow: 1,
background: theme.vars.palette.background.level2,
@@ -139,8 +144,8 @@ export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) {
<Composer
conversationId={activeConversationId} messageId={null}
sendMessage={handleSendMessage}
isDeveloperMode={systemPurposeId === 'Developer'}
onSendMessage={handleSendMessage}
sx={{
position: 'sticky', bottom: 0, zIndex: 21,
background: theme.vars.palette.background.surface,
@@ -163,12 +168,6 @@ export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) {
<PublishedModal open onClose={() => setPublishResponse(null)} response={publishResponse} />
)}
{/* Confirmation for Delete */}
<ConfirmationModal
open={!!clearConfirmationId} onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'}
/>
</Box>
);
+9 -11
View File
@@ -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
</MenuItem>
<ListDivider />
{fromUser && (
<MenuItem onClick={handleMenuRunAgain} disabled={props.disableSend}>
<MenuItem onClick={handleMenuRunAgain}>
<ListItemDecorator><FastForwardIcon /></ListItemDecorator>
{props.lastMessage ? 'Run Again' : 'Restart From Here'}
{props.isLast ? 'Run Again' : 'Restart From Here'}
</MenuItem>
)}
<MenuItem onClick={props.onDelete} disabled={false /*fromSystem*/}>
<MenuItem onClick={props.onMessageDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete
</MenuItem>
+26 -26
View File
@@ -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<HTMLDivElement | null>(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 (
<Box sx={props.sx || {}}>
<PurposeSelector onRunExample={handleRunExample} />
</Box>
);
if (!filteredMessages.length)
return !props.conversationId ? null
: <Box sx={props.sx || {}}>
<PurposeSelector conversationId={props.conversationId} runExample={handleRunPurposeExample} />
</Box>;
return (
<Box sx={props.sx || {}}>
@@ -64,11 +65,10 @@ export function ChatMessageList(props: { disableSend: boolean, sx?: SxProps, run
<ChatMessage
key={'msg-' + message.id}
message={message}
disableSend={props.disableSend}
lastMessage={idx === filteredMessages.length - 1}
onDelete={() => 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)} />,
)}
<div ref={messagesEndRef}></div>
+31 -19
View File
@@ -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,
}} />
<TokenBadge directTokens={directTokens} indirectTokens={indirectTokens} tokenLimit={tokenLimit} absoluteBottomRight />
{!!tokenLimit && <TokenBadge directTokens={directTokens} indirectTokens={indirectTokens} tokenLimit={tokenLimit} absoluteBottomRight />}
<Card
color='primary' invertedColors variant='soft'
@@ -425,10 +437,10 @@ export function Composer(props: {
{/* Send / Stop */}
{assistantTyping
? <Button fullWidth variant='soft' color='primary' onClick={handleStopClicked} endDecorator={<StopOutlinedIcon />}>
? <Button fullWidth variant='soft' color='primary' disabled={!props.conversationId} onClick={handleStopClicked} endDecorator={<StopOutlinedIcon />}>
Stop
</Button>
: <Button fullWidth variant='solid' color='primary' onClick={handleSendClicked} endDecorator={<TelegramIcon />}>
: <Button fullWidth variant='solid' color='primary' disabled={!props.conversationId} onClick={handleSendClicked} endDecorator={<TelegramIcon />}>
Chat
</Button>}
</Box>
@@ -463,7 +475,7 @@ export function Composer(props: {
)}
{/* Content reducer modal */}
{reducerText?.length >= 1 &&
{reducerText?.length >= 1 && chatModelId &&
<ContentReducerModal
initialText={reducerText} initialTokens={reducerTextTokens} tokenLimit={remainingTokens} chatModelId={chatModelId}
onReducedText={handleContentReducerText} onClose={handleContentReducerClose}
+172 -54
View File
@@ -1,80 +1,198 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { IconButton, ListItem, ListItemDecorator, Menu, MenuItem, Typography } from '@mui/joy';
import { Avatar, Box, IconButton, ListItemDecorator, Menu, MenuItem, Tooltip, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { Link } from '@/components/util/Link';
import { SystemPurposeId, SystemPurposes } from '@/lib/data';
import { useChatStore, useConversationNames } from '@/lib/store-chats';
import { ConfirmationModal } from '@/components/dialogs/ConfirmationModal';
// import { Link } from '@/components/util/Link';
import { SystemPurposes } from '@/lib/data';
import { createDefaultConversation, MAX_CONVERSATIONS, useChatStore, useConversationIDs } from '@/lib/store-chats';
const DEBUG_CONVERSATION_IDs = false;
function ConversationListItem(props: {
conversationId: string,
isActive: boolean, isSingle: boolean,
conversationActivate: (conversationId: string) => 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 (
<MenuItem
variant={props.isActive ? 'solid' : 'plain'} color='neutral'
onClick={() => props.conversationActivate(props.conversationId)}
// sx={{ '&:hover > button': { opacity: 1 } }}
>
{/* Icon */}
<ListItemDecorator>
{assistantTyping
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
sx={{
width: 24,
height: 24,
borderRadius: 8,
}}
/>
) : (
<Typography sx={{ fontSize: '18px' }}>
{/*<Badge size='sm' variant='solid' color='primary' badgeContent={'3.5'}>*/}
{textSymbol}
{/*</Badge>*/}
</Typography>
)}
</ListItemDecorator>
{/* Text */}
<Box onDoubleClick={() => props.conversationEditTitle(props.conversationId)} sx={{ mr: 1 }}>
{DEBUG_CONVERSATION_IDs ? props.conversationId.slice(0, 10) : name}{assistantTyping && '...'}
</Box>
{/* Edit */}
{/*<IconButton*/}
{/* variant='plain' color='neutral'*/}
{/* onClick={() => props.onEditTitle(props.conversationId)}*/}
{/* sx={{*/}
{/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/}
{/* }}>*/}
{/* <EditIcon />*/}
{/*</IconButton>*/}
{/* Clear */}
{!props.isSingle && (
<IconButton
variant='outlined' color='neutral'
size='sm' sx={{ ml: 'auto', ...(props.isActive && { color: 'white' }) }}
onClick={e => props.conversationDelete(e, props.conversationId)}>
<DeleteOutlineIcon />
</IconButton>
)}
</MenuItem>
);
}
/**
* 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<string | null>(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 <Menu
variant='plain' color='neutral' size='lg' placement='bottom-start' sx={{ minWidth: 280 }}
open={!!props.pagesMenuAnchor} anchorEl={props.pagesMenuAnchor} onClose={props.onClose}
disablePortal={false}>
const singleChat = conversationIDs.length === 1;
const maxReached = conversationIDs.length >= MAX_CONVERSATIONS;
<ListItem>
<Typography level='body2'>
Active chats
</Typography>
</ListItem>
{conversationNames.map((conversation) => (
<MenuItem
key={'c-id-' + conversation.id}
onClick={() => handleConversationClicked(conversation.id)}
>
const handleNew = () => addConversation(createDefaultConversation(), true);
<ListItemDecorator>
{SystemPurposes[conversation.systemPurposeId]?.symbol || ''}
</ListItemDecorator>
const handleConversationActivate = (conversationId: string) => setActiveConversation(conversationId);
<Typography sx={{ mr: 2 }}>
{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 && <Tooltip title={`Max reached:${MAX_CONVERSATIONS} chats. The oldest will be eliminated.`}><span></span></Tooltip>;
return <>
<Menu
variant='plain' color='neutral' size='lg' placement='bottom-start' sx={{ minWidth: 320 }}
open={!!props.pagesMenuAnchor} anchorEl={props.pagesMenuAnchor} onClose={props.onClose}
disablePortal={false}>
{/*<ListItem>*/}
{/* <Typography level='body2'>*/}
{/* Active chats*/}
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem onClick={handleNew}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Typography>
New chat {newSuffix}
</Typography>
<IconButton
variant='soft' color='neutral' sx={{ ml: 'auto' }}
onClick={e => props.onClearConversation(e, conversation.id)}>
<DeleteOutlineIcon />
</IconButton>
</MenuItem>
))}
<MenuItem disabled={true}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Typography sx={{ opacity: 0.5 }}>
New chat (soon)
{/* We need stable Chat and Message IDs, and one final review to the data structure of Conversation for future-proofing */}
</Typography>
</MenuItem>
{conversationIDs.map(conversationId =>
<ConversationListItem
key={'c-id-' + conversationId}
conversationId={conversationId}
isActive={conversationId === activeConversationId}
isSingle={singleChat}
conversationActivate={handleConversationActivate}
conversationDelete={handleConversationDelete}
conversationEditTitle={handleConversationEditTitle}
/>)}
{/*<ListItem>*/}
{/* <Typography level='body2'>*/}
{/* Scratchpad*/}
{/* </Typography>*/}
{/*</ListItem>*/}
<ListItem>
<Typography level='body2'>
Scratchpad
</Typography>
</ListItem>
{/*<MenuItem>*/}
{/* <ListItemDecorator />*/}
{/* <Typography sx={{ opacity: 0.5 }}>*/}
{/* Feature <Link href='https://github.com/enricoros/nextjs-chatgpt-app/issues/17' target='_blank'>#17</Link>*/}
{/* </Typography>*/}
{/*</MenuItem>*/}
<MenuItem>
<ListItemDecorator />
<Typography sx={{ opacity: 0.5 }}>
Feature <Link href='https://github.com/enricoros/nextjs-chatgpt-app/issues/17' target='_blank'>#17</Link>
</Typography>
</MenuItem>
</Menu>
</Menu>;
{/* Confirmations */}
<ConfirmationModal
open={!!deleteConfirmationId} onClose={() => setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={'Are you sure you want to delete this conversation?'} positiveActionText={'Delete conversation'}
/>
</>;
}
+18 -7
View File
@@ -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 = <T extends any>(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<SystemPurposeId[] | null>(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<HTMLTextAreaElement>): void => {
@@ -158,7 +169,7 @@ export function PurposeSelector(props: { onRunExample: (example: string) => void
: (selectedExample ? <>
{selectedExample}
<IconButton variant='plain' color='primary' size='sm'
onClick={() => props.onRunExample(selectedExample)}
onClick={() => props.runExample(selectedExample)}
sx={{ opacity: 0, transition: 'opacity 0.3s' }}>
<TelegramIcon />
</IconButton>
+72 -89
View File
@@ -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<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) => 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<ChatStore>()(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<ChatStore>()(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<ChatStore>()(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<ChatStore>()(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,
);