mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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'}
|
||||
/>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user