Multi-Part refactor

Partial still. Does not build.
This commit is contained in:
Enrico Ros
2024-05-15 04:33:21 -07:00
parent 084e48ddc2
commit 9d347f4a5a
56 changed files with 896 additions and 672 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapa
// stores access
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
import { useAppStateStore } from '~/common/state/store-appstate';
import { useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFolderStore } from '~/common/state/store-folders';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
+2 -1
View File
@@ -8,7 +8,8 @@ import { BeamView } from '~/modules/beam/BeamView';
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
import { useModelsStore } from '~/modules/llms/store-llms';
import { createDConversation, createDMessage, DConversation, DMessage } from '~/common/state/store-chats';
import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation';
import { createDMessage, DMessage } from '~/common/stores/chat/chat.message';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { Container, Sheet } from '@mui/joy';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { useRouterQuery } from '~/common/app.routes';
import { CallWizard } from './CallWizard';
+1 -1
View File
@@ -13,7 +13,7 @@ import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptim
import { animationColorRainbow } from '~/common/util/animUtils';
import { navigateBack } from '~/common/app.routes';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useUICounter } from '~/common/state/store-ui';
+4 -4
View File
@@ -1,13 +1,13 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { DConversation, DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
import { animationShadowRingLimey } from '~/common/util/animUtils';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
@@ -60,7 +60,7 @@ const ContactCardConversationCall = (props: { conversation: DConversation, onCon
function CallContactCard(props: {
persona: MockPersona,
callGrayUI: boolean,
conversations: DConversation[],
conversations: Readonly<DConversation[]>,
setCallIntent: (intent: AppCallIntent) => void,
}) {
@@ -189,7 +189,7 @@ function CallContactCard(props: {
function useConversationsByPersona() {
const conversations = useChatStore(state => state.conversations, shallow);
const conversations = useChatStore(state => state.conversations);
return React.useMemo(() => {
// group by personaId
+10 -8
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useShallow } from 'zustand/react/shallow';
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -20,9 +20,11 @@ import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVo
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { conversationTitle } from '~/common/stores/chat/chat.conversation';
import { createDMessage, DMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
@@ -99,7 +101,7 @@ export function Telephone(props: {
// external state
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
const { chatTitle, reMessages } = useChatStore(state => {
const { chatTitle, reMessages } = useChatStore(useShallow(state => {
const conversation = props.callIntent.conversationId
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
: null;
@@ -107,7 +109,7 @@ export function Telephone(props: {
chatTitle: conversation ? conversationTitle(conversation) : null,
reMessages: conversation ? conversation.messages : null,
};
}, shallow);
}));
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
const personaCallStarters = persona?.call?.starters ?? undefined;
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
@@ -181,7 +183,7 @@ export function Telephone(props: {
// only act when we have a new user message
if (!isConnected || callMessages.length < 1 || callMessages[callMessages.length - 1].role !== 'user')
return;
switch (callMessages[callMessages.length - 1].text) {
switch (singleTextOrThrow(callMessages[callMessages.length - 1])) {
// do not respond
case 'Stop.':
return;
@@ -206,7 +208,7 @@ export function Telephone(props: {
// temp fix: when the chat has no messages, only assume a single system message
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
? reMessages
? reMessages.map(message => ({ role: message.role, text: singleTextOrThrow(message) }))
: personaSystemMessage
? [{ role: 'system', text: personaSystemMessage }]
: [];
@@ -217,7 +219,7 @@ export function Telephone(props: {
{ role: 'system', content: 'You are having a phone call. Your response style is brief and to the point, and according to your personality, defined below.' },
...chatMessages.map(message => ({ role: message.role, content: message.text })),
{ role: 'system', content: 'You are now on the phone call related to the chat above. Respect your personality and answer with short, friendly and accurate thoughtful lines.' },
...callMessages.map(message => ({ role: message.role, content: message.text })),
...callMessages.map(message => ({ role: message.role, content: singleTextOrThrow(message) })),
];
// perform completion
@@ -339,7 +341,7 @@ export function Telephone(props: {
{callMessages.map((message) =>
<CallMessage
key={message.id}
text={message.text}
text={singleTextOrThrow(message)}
variant={message.role === 'assistant' ? 'solid' : 'soft'}
color={message.role === 'assistant' ? 'neutral' : 'primary'}
role={message.role}
+5 -4
View File
@@ -17,13 +17,15 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DAttachmentPart, DMessage, DMessageMetadata, createDMessage } from '~/common/stores/chat/chat.message';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
import { PreferencesTab, useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, DMessageMetadata, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { useFolderStore } from '~/common/state/store-folders';
import { useIsMobile } from '~/common/components/useMatchMedia';
@@ -31,7 +33,6 @@ import { useRouterQuery } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatBarAltBeam } from './components/ChatBarAltBeam';
import { ChatBarAltTitle } from './components/ChatBarAltTitle';
import { ChatBarDropdowns } from './components/ChatBarDropdowns';
@@ -210,9 +211,9 @@ export function AppChat() {
return outcome === true;
}, [openModelsSetup, openPreferencesTab]);
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata): boolean => {
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: DAttachmentPart[], metadata?: DMessageMetadata): boolean => {
// validate inputs
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'atext') {
addSnackbar({
key: 'chat-composer-action-invalid',
message: 'Only a single text part is supported for now.',
+1 -1
View File
@@ -5,7 +5,7 @@ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DConversationId } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { CHAT_NOVEL_TITLE } from '../AppChat';
@@ -1,6 +1,6 @@
import * as React from 'react';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
+1 -1
View File
@@ -12,7 +12,7 @@ import FolderIcon from '@mui/icons-material/Folder';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { DebounceInputMemo } from '~/common/components/DebounceInput';
+2 -1
View File
@@ -15,10 +15,11 @@ import { SystemPurposeId, SystemPurposes } from '../../../data';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DFolder } from '~/common/state/store-folders';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { isDeepEqual } from '~/common/util/jsUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { CHAT_NOVEL_TITLE } from '../AppChat';
import { STREAM_TEXT_INDICATOR } from '../editors/chat-stream';
+9 -4
View File
@@ -6,11 +6,13 @@ import { Box, List } from '@mui/joy';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import { DMessage, DMessageUserFlag, contentPartsReplaceText, createDMessage, messageToggleUserFlag, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { InlineError } from '~/common/components/InlineError';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { createDMessage, DConversationId, DMessage, DMessageUserFlag, getConversation, messageToggleUserFlag, useChatStore } from '~/common/state/store-chats';
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useEphemerals } from '~/common/chats/EphemeralsStore';
@@ -127,7 +129,9 @@ export function ChatMessageList(props: {
}, [conversationId, deleteMessage]);
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
conversationId && editMessage(conversationId, messageId, (message): Partial<DMessage> => ({
content: contentPartsReplaceText(message, newText),
}), true);
}, [conversationId, editMessage]);
const handleMessageToggleUserFlag = React.useCallback((messageId: string, userFlag: DMessageUserFlag) => {
@@ -195,8 +199,9 @@ export function ChatMessageList(props: {
const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const textB = msgB ? singleTextOrThrow(msgB) : undefined;
const textA = msgA ? singleTextOrThrow(msgA) : undefined;
if (textB && textA && !msgB?.typing) {
const lenA = textA.length, lenB = textB.length;
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
return { diffTargetMessage: msgB, diffPrevText: textA };
@@ -13,7 +13,7 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
+2 -2
View File
@@ -4,9 +4,9 @@ import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DEphemeral } from '~/common/chats/EphemeralsStore';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DConversationId } from '~/common/state/store-chats';
import { DEphemeral } from '~/common/chats/EphemeralsStore';
import { lineHeightChatTextMd } from '~/common/app.theme';
+12 -10
View File
@@ -24,11 +24,13 @@ import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DAttachmentPart, DMessageMetadata, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { animationEnterBelow } from '~/common/util/animUtils';
import { conversationTitle, DConversationId, DMessageMetadata, getConversation, useChatStore } from '~/common/state/store-chats';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { countModelTokens } from '~/common/util/token-counter';
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
import { isMacUser } from '~/common/util/pwaUtils';
import { launchAppCall } from '~/common/app.routes';
import { lineHeightTextareaMd } from '~/common/app.theme';
@@ -53,7 +55,6 @@ import { Attachments } from './attachments/Attachments';
import { getSingleTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
import { useAttachments } from './attachments/useAttachments';
import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
@@ -101,7 +102,7 @@ export function Composer(props: {
capabilityHasT2I: boolean;
isMulticast: boolean | null;
isDeveloperMode: boolean;
onAction: (conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata) => boolean;
onAction: (conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: DAttachmentPart[], metadata?: DMessageMetadata) => boolean;
onTextImagine: (conversationId: DConversationId, text: string) => void;
setIsMulticast: (on: boolean) => void;
sx?: SxProps;
@@ -129,13 +130,13 @@ export function Composer(props: {
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(useShallow(state => {
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, abortTyping } = useChatStore(useShallow(state => {
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
return {
assistantAbortible: conversation ? !!conversation.abortController : false,
systemPurposeId: conversation?.systemPurposeId ?? null,
tokenCount: conversation ? conversation.tokenCount : 0,
stopTyping: state.stopTyping,
abortTyping: state.abortTyping,
};
}));
const { inComposer: browsingInComposer } = useBrowseCapability();
@@ -234,8 +235,8 @@ export function Composer(props: {
}, [composeText, handleSendAction]);
const handleStopClicked = React.useCallback(() => {
!!props.conversationId && stopTyping(props.conversationId);
}, [props.conversationId, stopTyping]);
!!props.conversationId && abortTyping(props.conversationId);
}, [abortTyping, props.conversationId]);
// Secondary buttons
@@ -299,12 +300,13 @@ export function Composer(props: {
// get the message
const conversation = getConversation(item.conversationId);
const messageToAttach = conversation?.messages.find(m => m.id === item.messageId);
if (conversation && messageToAttach && messageToAttach.text) {
const messageText = messageToAttach ? singleTextOrThrow(messageToAttach) : null;
if (conversation && messageToAttach && messageText) {
// Testing with this serialization for LLM. Note it will still be within a multi-part message,
// this could be in a titled markdown block. Don't know yet how this fares with different LLMs.
const chatTitle = conversationTitle(conversation);
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageToAttach.text.trim()}\n`;
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageToAttach.text.slice(0, 10)}...`);
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageText.trim()}\n`;
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageText.slice(0, 10)}...`);
}
}, [attachAppendEgoMessage]);
@@ -1,4 +1,6 @@
import { conversationTitle, DConversationId, messageHasUserFlag, useChatStore } from '~/common/state/store-chats';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { messageHasUserFlag, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { ActileItem, ActileProvider } from './ActileProvider';
@@ -27,7 +29,7 @@ export function providerStarredMessage(onMessageSeelect: (item: StarredMessageIt
messageId: message.id,
// looks
key: message.id,
label: conversationTitle(conversation) + ' - ' + message.text.slice(0, 32) + '...',
label: conversationTitle(conversation) + ' - ' + singleTextOrThrow(message).slice(0, 32) + '...',
// description: message.text.slice(32, 100),
Icon: undefined,
} satisfies StarredMessageItem);
@@ -1,22 +0,0 @@
export type ComposerOutputPartType = 'text-block' | 'image-part';
export type ComposerOutputPart = {
type: 'text-block',
text: string,
title: string | null,
collapsible: boolean,
} | {
// TODO: not implemented yet
type: 'image-part',
base64Url: string,
metadata: {
title?: string,
generatedBy?: string,
altText?: string,
width?: number,
height?: number,
},
collapsible: false,
};
export type ComposerOutputMultiPart = ComposerOutputPart[];
@@ -3,7 +3,7 @@ import * as React from 'react';
import ClearIcon from '@mui/icons-material/Clear';
import FolderIcon from '@mui/icons-material/Folder';
import type { DConversationId } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useFolderStore } from '~/common/state/store-folders';
@@ -30,7 +30,7 @@ import { useSanityTextDiffs } from '~/modules/blocks/RenderTextDiff';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DMessage, DMessageUserFlag, messageHasUserFlag } from '~/common/state/store-chats';
import { DMessage, DMessageRole, DMessageUserFlag, messageHasUserFlag, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { KeyStroke } from '~/common/components/KeyStroke';
import { Link } from '~/common/components/Link';
@@ -53,7 +53,7 @@ const SELECTION_TOOLBAR_MIN_LENGTH = 3;
const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false;
export function messageBackground(messageRole: DMessage['role'] | string, wasEdited: boolean, isAssistantIssue: boolean): string {
export function messageBackground(messageRole: DMessageRole | string, wasEdited: boolean, isAssistantIssue: boolean): string {
switch (messageRole) {
case 'user':
return 'primary.plainHoverBg'; // was .background.level1
@@ -87,7 +87,7 @@ const personaSx: SxProps = {
};
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
export function makeAvatar(messageAvatar: string | null, messageRole: DMessageRole | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | string | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
if (typeof messageAvatar === 'string' && messageAvatar)
return <Avatar alt={messageSender} src={messageAvatar} />;
@@ -124,7 +124,7 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
}} />;
// purpose symbol (if present)
const symbol = SystemPurposes[messagePurposeId!]?.symbol;
const symbol = SystemPurposes[messagePurposeId as SystemPurposeId]?.symbol;
if (symbol)
return <Box sx={{
fontSize: '24px',
@@ -256,12 +256,14 @@ export function ChatMessage(props: {
renderMarkdown: state.renderMarkdown,
})));
const [showDiff, setShowDiff] = useChatShowTextDiff();
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
const messageText = singleTextOrThrow(props.message);
const textDiffs = useSanityTextDiffs(messageText, props.diffPreviousText, showDiff);
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
typing: messageTyping,
@@ -4,7 +4,7 @@ import { Box, Button, Checkbox, IconButton, ListItem, Sheet, Typography } from '
import ClearIcon from '@mui/icons-material/Clear';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { DMessage } from '~/common/state/store-chats';
import { DMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { TokenBadgeMemo } from '../composer/TokenBadge';
import { makeAvatar, messageBackground } from './ChatMessage';
@@ -44,7 +44,6 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
typing: messageTyping,
@@ -55,6 +54,8 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
updated: messageUpdated,
} = props.message;
const messageText = singleTextOrThrow(props.message);
const fromAssistant = messageRole === 'assistant';
const isAssistantError = fromAssistant && (messageText.startsWith('[Issue] ') || messageText.startsWith('[OpenAI Issue]'));
@@ -4,7 +4,8 @@ import { persist } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
import { v4 as uuidv4 } from 'uuid';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { useChatStore } from '~/common/stores/chat/store-chats';
// change this to increase/decrease the number history steps per pane
@@ -1,6 +1,5 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { v4 as uuidv4 } from 'uuid';
import type { SxProps } from '@mui/joy/styles/types';
import { Alert, Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
@@ -16,10 +15,12 @@ import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { useChatLLM } from '~/modules/llms/store-llms';
import { DConversationId, DMessage, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
import { createDMessage } from '~/common/stores/chat/chat.message';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { navigateToPersonas } from '~/common/app.routes';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useChipBoolean } from '~/common/components/useChipBoolean';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -187,17 +188,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
const conversationId = props.conversationId;
// Create a new message object
const newMessage: DMessage = {
id: uuidv4(),
text: messageText,
sender: 'Bot',
avatar: null,
typing: false,
role: 'assistant' as 'assistant',
tokenCount: 0,
created: Date.now(),
updated: null,
};
const newMessage = createDMessage('assistant', messageText);
// Append the new message to the conversation
appendMessage(conversationId, newMessage);
@@ -1,7 +1,9 @@
import { shallow } from 'zustand/shallow';
import type { DFolder } from '~/common/state/store-folders';
import { conversationTitle, DConversationId, DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, useChatStore } from '~/common/state/store-chats';
import { DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { useChatStore } from '~/common/stores/chat/store-chats';
import type { ChatNavigationItemData } from './ChatDrawerItem';
@@ -119,7 +121,7 @@ export function useChatDrawerRenderItems(
let searchFrequency: number = 0;
if (isSearching) {
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
const messageFrequency = _c.messages.reduce((count, message) => count + (message.text.toLowerCase().split(lcTextQuery).length - 1), 0);
const messageFrequency = _c.messages.reduce((count, message) => count + (singleTextOrThrow(message).toLowerCase().split(lcTextQuery).length - 1), 0);
searchFrequency = titleFrequency + messageFrequency;
}
@@ -3,8 +3,9 @@ import { shallow } from 'zustand/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { usePurposeStore } from './persona-selector/store-purposes';
+11 -8
View File
@@ -2,7 +2,9 @@ import { getChatLLMId } from '~/modules/llms/store-llms';
import { updateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { createDMessage, DConversationId, DMessage, getConversationSystemPurposeId } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { DMessage, createDMessage, createTextPart, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import { extractChatCommand, findAllChatCommands } from '../commands/commands.registry';
@@ -44,8 +46,9 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
const lastMessageText = lastMessage ? singleTextOrThrow(lastMessage) : '';
if (lastMessage?.role === 'user') {
const chatCommand = extractChatCommand(lastMessage.text)[0];
const chatCommand = extractChatCommand(lastMessageText)[0];
if (chatCommand && chatCommand.type === 'cmd') {
switch (chatCommand.providerId) {
case 'ass-browse':
@@ -75,7 +78,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
Object.assign(lastMessage, {
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
sender: 'Bot',
text: chatCommand.params || '',
content: [createTextPart(chatCommand.params || '')],
} satisfies Partial<DMessage>);
cHandler.messagesReplace(history);
return true;
@@ -130,18 +133,18 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
return true;
case 'generate-image':
if (!lastMessage?.text) break;
if (!lastMessage || !lastMessageText) break;
// also add a 'fake' user message with the '/draw' command
cHandler.messagesReplace(history.map(message => (message.id !== lastMessage.id) ? message : {
...message,
text: `/draw ${lastMessage.text}`,
text: `/draw ${lastMessageText}`,
}));
return await runImageGenerationUpdatingState(cHandler, lastMessage.text);
return await runImageGenerationUpdatingState(cHandler, lastMessageText);
case 'generate-react':
if (!lastMessage?.text) break;
if (!lastMessage || !lastMessageText) break;
cHandler.messagesReplace(history);
return await runReActUpdatingState(cHandler, lastMessage.text, chatLLMId);
return await runReActUpdatingState(cHandler, lastMessageText, chatLLMId);
}
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
+11 -7
View File
@@ -5,7 +5,7 @@ import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import type { DMessage } from '~/common/state/store-chats';
import { DMessage, createTextPart, singleTextOrThrow, singleTextOrThrow2 } from '~/common/stores/chat/chat.message';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
@@ -33,7 +33,7 @@ export async function runAssistantUpdatingState(conversationId: string, history:
// stream the assistant's messages
const messageStatus = await streamAssistantMessage(
assistantLlmId,
history.map((m): VChatMessageIn => ({ role: m.role, content: m.text })),
history.map((m): VChatMessageIn => ({ role: m.role, content: singleTextOrThrow(m) })),
parallelViewCount,
autoSpeak,
(update) => cHandler.messageEdit(assistantMessageId, update, false),
@@ -89,7 +89,7 @@ export async function streamAssistantMessage(
}
}
const incrementalAnswer: Partial<DMessage> = { text: '' };
const incrementalAnswer: Partial<DMessage> = { content: [createTextPart('')] };
try {
await llmStreamingChatGenerate(llmId, messagesHistory, null, null, abortSignal, (update: StreamingClientUpdate) => {
@@ -97,7 +97,7 @@ export async function streamAssistantMessage(
// grow the incremental message
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
if (textSoFar) incrementalAnswer.text = textSoFar;
if (textSoFar) incrementalAnswer.content = [createTextPart(textSoFar)];
if (update.typing !== undefined) incrementalAnswer.typing = update.typing;
// Update the data store, with optional max-frequency throttling (e.g. OpenAI is downsamped 50 -> 12Hz)
@@ -121,7 +121,8 @@ export async function streamAssistantMessage(
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
incrementalAnswer.text = (incrementalAnswer.text || '') + errorText;
const incrementalText = singleTextOrThrow2(incrementalAnswer.content);
incrementalAnswer.content = [createTextPart(incrementalText + errorText)];
returnStatus.outcome = 'errored';
returnStatus.errorMessage = error.message;
} else
@@ -134,8 +135,11 @@ export async function streamAssistantMessage(
editMessage({ ...incrementalAnswer, typing: false });
// 📢 TTS: all
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && incrementalAnswer.text && !spokenLine && !abortSignal.aborted)
void speakText(incrementalAnswer.text);
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && !spokenLine && !abortSignal.aborted) {
const incrementalText = singleTextOrThrow2(incrementalAnswer.content);
if (incrementalText.length > 0)
void speakText(incrementalText);
}
return returnStatus;
}
+3 -3
View File
@@ -13,17 +13,17 @@ import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle } from '~/common/state/store-chats';
import { conversationTitle } from '~/common/stores/chat/chat.conversation';
import { navigateToChatLinkList } from '~/common/app.routes';
import { themeBgAppDarker } from '~/common/app.theme';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { LinkChatDrawer } from './LinkChatDrawer';
import { LinkChatPageMenuItems } from './LinkChatPageMenuItems';
import { LinkChatViewer } from './LinkChatViewer';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { navigateToChatLinkList } from '~/common/app.routes';
const SPECIAL_LIST_PAGE_ID = 'list';
+5 -3
View File
@@ -8,11 +8,13 @@ import { ChatMessageMemo } from '../chat/components/message/ChatMessage';
import { useChatShowSystemMessages } from '../chat/store-app-chat';
import { Brand } from '~/common/app.config';
import { DConversation, conversationTitle } from '~/common/stores/chat/chat.conversation';
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats';
import { createTextPart, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { launchAppChat } from '~/common/app.routes';
import { themeBgAppDarker } from '~/common/app.theme';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -42,7 +44,7 @@ export function LinkChatViewer(props: { conversation: DConversation, storedAt: D
React.useEffect(() => {
const { renderMarkdown, setRenderMarkdown } = useUIPreferencesStore.getState();
if (!renderMarkdown) {
const hasMarkdownTables = messages.some(m => m.text.includes('|---'));
const hasMarkdownTables = messages.some(m => singleTextOrThrow(m).includes('|---'));
if (hasMarkdownTables) {
setRenderMarkdown(true);
console.log('Turning on Markdown because of tables');
@@ -136,7 +138,7 @@ export function LinkChatViewer(props: { conversation: DConversation, storedAt: D
message={message}
fitScreen={isMobile}
showBlocksDate={idx === 0 || idx === filteredMessages.length - 1 /* first and last message */}
onMessageEdit={(_messageId, text: string) => message.text = text}
onMessageEdit={(_messageId, text: string) => message.content = [createTextPart(text)]}
/>,
)}
+2 -1
View File
@@ -9,7 +9,8 @@ import Router, { useRouter } from 'next/router';
import type { AppCallIntent } from '../apps/call/AppCall';
import type { AppChatIntent } from '../apps/chat/AppChat';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { isBrowser } from './util/pwaUtils';
+8 -5
View File
@@ -3,10 +3,12 @@ import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { SystemPurposeId, SystemPurposes } from '../../data';
import { ChatActions, createDMessage, DConversationId, DMessage, getConversationSystemPurposeId, useChatStore } from '../state/store-chats';
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
import { ChatActions, getConversationSystemPurposeId, useChatStore } from '~/common/stores/chat/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { createDMessage, createTextPart, DMessage, fixmeThisReplacesAllParts } from '~/common/stores/chat/chat.message';
import { EphemeralHandler, EphemeralsStore } from './EphemeralsStore';
import { createChatOverlayVanillaStore } from './store-chat-overlay-vanilla';
@@ -40,7 +42,8 @@ export class ConversationHandler {
let systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
systemMessage.purposeId = purposeId;
systemMessage.text = bareBonesPromptMixer(SystemPurposes[purposeId].systemMessage, assistantLlmId);
const systemMessageText = bareBonesPromptMixer(SystemPurposes[purposeId].systemMessage, assistantLlmId);
systemMessage.content = [createTextPart(systemMessageText)];
// HACK: this is a special case for the 'Custom' persona, to set the message in stone (so it doesn't get updated when switching to another persona)
if (purposeId === 'Custom')
@@ -68,7 +71,7 @@ export class ConversationHandler {
* @param purposeId purpose that supposedly triggered this message
* @param typing whether the assistant is typing at the onset
*/
messageAppendAssistant(text: string, purposeId: SystemPurposeId | undefined, llmLabel: DLLMId | string, typing: boolean): string {
messageAppendAssistant(text: string, purposeId: SystemPurposeId | string | undefined, llmLabel: DLLMId | string, typing: boolean): string {
const assistantMessage: DMessage = createDMessage('assistant', text);
assistantMessage.typing = typing;
assistantMessage.purposeId = purposeId ?? undefined;
@@ -108,7 +111,7 @@ export class ConversationHandler {
// set output when going back to the chat
if (destReplaceMessageId) {
// replace a single message in the conversation history
this.messageEdit(destReplaceMessageId, { text: messageText, originLLM: llmId }, true);
this.messageEdit(destReplaceMessageId, { content: fixmeThisReplacesAllParts(messageText), originLLM: llmId }, true);
} else {
// replace (may truncate) the conversation history and append a message
const newMessage = createDMessage('assistant', messageText);
+2 -1
View File
@@ -1,7 +1,8 @@
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { ConversationHandler } from './ConversationHandler';
/**
* Singleton to get a global instance related to a conversationId. Note we don't have reference counting, and mainly because we cannot
* do comprehensive lifecycle tracking.
-513
View File
@@ -1,513 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
import { v4 as uuidv4 } from 'uuid';
import { DLLMId, getChatLLMId } from '~/modules/llms/store-llms';
import { idbStateStorage } from '../util/idbUtils';
import { countModelTokens } from '../util/token-counter';
import { defaultSystemPurposeId, SystemPurposeId } from '../../data';
export type DConversationId = string;
/**
* Conversation, a list of messages between humans and bots
* Future:
* - draftUserMessage?: { text: string; attachments: any[] };
* - isMuted: boolean; isArchived: boolean; Starred: boolean; participants: string[];
*/
export interface DConversation {
id: DConversationId;
messages: DMessage[];
systemPurposeId: SystemPurposeId;
userTitle?: string;
autoTitle?: string;
tokenCount: number; // f(messages, llmId)
created: number; // created timestamp (Date.now())
updated: number | null; // updated timestamp (Date.now())
// Not persisted, used while in-memory, or temporarily by the UI
abortController: AbortController | null;
}
export function createDConversation(systemPurposeId?: SystemPurposeId): DConversation {
return {
id: uuidv4(),
messages: [],
systemPurposeId: systemPurposeId || defaultSystemPurposeId,
tokenCount: 0,
created: Date.now(),
updated: Date.now(),
abortController: null,
};
}
const defaultConversations: DConversation[] = [createDConversation()];
/**
* Message, sent or received, by humans or bots
*
* Other ideas:
* - attachments?: {type: string; url: string; thumbnailUrl?: string; size?: number}[];
* - isPinned?: boolean;
* - reactions?: {type: string; count: number; users: string[]}[];
* - status: 'sent' | 'delivered' | 'read' | 'failed';
*/
export interface DMessage {
id: string;
text: string;
sender: 'You' | 'Bot' | string; // pretty name
avatar: string | null; // null, or image url
typing: boolean;
role: 'assistant' | 'system' | 'user';
purposeId?: SystemPurposeId; // only assistant/system
originLLM?: string; // only assistant - model that generated this message, goes beyond known models
metadata?: DMessageMetadata; // metadata, mainly at creation and for UI
userFlags?: DMessageUserFlag[]; // (UI) user-set per-message flags
tokenCount: number; // cache for token count, using the current Conversation model (0 = not yet calculated)
created: number; // created timestamp
updated: number | null; // updated timestamp
}
export type DMessageUserFlag =
| 'starred'; // user starred this
export interface DMessageMetadata {
inReplyToText?: string; // text this was in reply to
}
export function createDMessage(role: DMessage['role'], text: string): DMessage {
return {
id: uuidv4(),
text,
sender: role === 'user' ? 'You' : 'Bot',
avatar: null,
typing: false,
role: role,
tokenCount: 0,
created: Date.now(),
updated: null,
};
}
export function messageHasUserFlag(message: DMessage, flag: DMessageUserFlag): boolean {
return message.userFlags?.includes(flag) ?? false;
}
export function messageToggleUserFlag(message: DMessage, flag: DMessageUserFlag): DMessageUserFlag[] {
if (message.userFlags?.includes(flag))
return message.userFlags.filter(_f => _f !== flag);
else
return [...(message.userFlags || []), flag];
}
const dMessageUserFlagToEmojiMap: Record<DMessageUserFlag, string> = {
starred: '⭐️',
};
export function messageUserFlagToEmoji(flag: DMessageUserFlag): string {
return dMessageUserFlagToEmojiMap[flag] || '❓';
}
/// Conversations Store
interface ChatState {
conversations: DConversation[];
}
export interface ChatActions {
// store setters
prependNewConversation: (personaId: SystemPurposeId | undefined) => DConversationId;
importConversation: (conversation: DConversation, preventClash: boolean) => DConversationId;
branchConversation: (conversationId: DConversationId, messageId: string | null) => DConversationId | null;
deleteConversations: (conversationIds: DConversationId[], newConversationPersonaId?: SystemPurposeId) => DConversationId;
// within a conversation
setAbortController: (conversationId: string, abortController: AbortController | null) => void;
stopTyping: (conversationId: string) => void;
setMessages: (conversationId: string, messages: DMessage[]) => void;
appendMessage: (conversationId: string, message: DMessage) => void;
deleteMessage: (conversationId: string, messageId: string) => void;
editMessage: (conversationId: string, messageId: string, update: Partial<DMessage> | ((message: DMessage) => Partial<DMessage>), touchUpdated: boolean) => void;
updateMetadata: (conversationId: string, messageId: string, metadataDelta: Partial<DMessageMetadata>, touchUpdated?: boolean) => void;
setSystemPurposeId: (conversationId: string, systemPurposeId: SystemPurposeId) => void;
setAutoTitle: (conversationId: string, autoTitle: string) => void;
setUserTitle: (conversationId: string, userTitle: string) => void;
// utility function
_editConversation: (conversationId: string, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) => void;
}
type ConversationsStore = ChatState & ChatActions;
export const useChatStore = create<ConversationsStore>()(devtools(
persist(
(_set, _get) => ({
// default state
conversations: defaultConversations,
prependNewConversation: (personaId: SystemPurposeId | undefined): DConversationId => {
const newConversation = createDConversation(personaId);
_set(state => ({
conversations: [
newConversation,
...state.conversations,
],
}));
return newConversation.id;
},
importConversation: (conversation: DConversation, preventClash: boolean): DConversationId => {
const { conversations } = _get();
// if there's a clash, abort the former conversation, and optionally change the ID
const existing = conversations.find(_c => _c.id === conversation.id);
if (existing) {
existing?.abortController?.abort();
if (preventClash) {
conversation.id = uuidv4();
console.warn('Conversation ID clash, changing ID to', conversation.id);
}
}
conversation.tokenCount = updateTokenCounts(conversation.messages, true, 'importConversation');
_set({
conversations: [
conversation,
...conversations.filter(_c => _c.id !== conversation.id),
],
});
return conversation.id;
},
branchConversation: (conversationId: DConversationId, messageId: string | null): DConversationId | null => {
const { conversations } = _get();
const conversation = conversations.find(_c => _c.id === conversationId);
if (!conversation)
return null;
// create a deep copy of the conversation
const deepCopy: DConversation = JSON.parse(JSON.stringify(conversation));
let messageIndex = deepCopy.messages.length; // By default, include all messages if messageId is null
if (messageId !== null) {
messageIndex = deepCopy.messages.findIndex(_m => _m.id === messageId);
messageIndex = messageIndex >= 0 ? messageIndex + 1 : deepCopy.messages.length; // If message is found, include it
}
// title this branched chat differently
const newTitle = getNextBranchTitle(conversationTitle(conversation));
const branched: DConversation = {
...deepCopy,
id: uuidv4(), // roll conversation ID
messages: deepCopy.messages
.slice(0, messageIndex)
.map((message: DMessage): DMessage => ({
...message,
id: uuidv4(), // roll message ID
typing: false,
})),
updated: Date.now(),
// Set the new title for the branched conversation
autoTitle: newTitle,
// reset transient
abortController: null,
// TODO: set references to parent conversation & message?
};
_set({
conversations: [
branched,
...conversations,
],
});
return branched.id;
},
deleteConversations: (conversationIds: DConversationId[], newConversationPersonaId?: SystemPurposeId): DConversationId => {
let { conversations } = _get();
// find the index of first conversation to delete
const cIndex = conversationIds.length > 0 ? conversations.findIndex(_c => _c.id === conversationIds[0]) : -1;
// abort all pending requests
conversationIds.forEach(conversationId => conversations.find(_c => _c.id === conversationId)?.abortController?.abort());
// remove from the list
conversations = conversations.filter(_c => !conversationIds.includes(_c.id));
// create a new conversation if there are no more
if (!conversations.length)
conversations.push(createDConversation(newConversationPersonaId));
_set({
conversations,
});
// return the next conversation Id in line, if valid
return conversations[(cIndex >= 0 && cIndex < conversations.length) ? cIndex : 0].id;
},
// within a conversation
_editConversation: (conversationId: string, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) =>
_set(state => ({
conversations: state.conversations.map((conversation: DConversation): DConversation =>
conversation.id === conversationId
? {
...conversation,
...(typeof update === 'function' ? update(conversation) : update),
}
: conversation),
})),
setAbortController: (conversationId: string, abortController: AbortController | null) =>
_get()._editConversation(conversationId, () =>
({
abortController: abortController,
})),
stopTyping: (conversationId: string) =>
_get()._editConversation(conversationId, conversation => {
conversation.abortController?.abort();
return {
abortController: null,
};
}),
setMessages: (conversationId: string, newMessages: DMessage[]) =>
_get()._editConversation(conversationId, conversation => {
conversation.abortController?.abort();
return {
messages: newMessages,
...(!!newMessages.length ? {} : {
autoTitle: undefined,
}),
tokenCount: updateTokenCounts(newMessages, false, 'setMessages'),
updated: Date.now(),
abortController: null,
};
}),
appendMessage: (conversationId: string, message: DMessage) =>
_get()._editConversation(conversationId, conversation => {
if (!message.typing)
updateTokenCounts([message], true, 'appendMessage');
const messages = [...conversation.messages, message];
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: Date.now(),
};
}),
deleteMessage: (conversationId: string, messageId: string) =>
_get()._editConversation(conversationId, conversation => {
const messages = conversation.messages.filter(message => message.id !== messageId);
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: Date.now(),
};
}),
editMessage: (conversationId: string, messageId: string, update: Partial<DMessage> | ((message: DMessage) => Partial<DMessage>), touchUpdated: boolean) =>
_get()._editConversation(conversationId, conversation => {
const chatLLMId = getChatLLMId();
const messages = conversation.messages.map((message: DMessage): DMessage => {
if (message.id === messageId) {
const updatedMessage = typeof update === 'function' ? update(message) : update;
return {
...message,
...updatedMessage,
...(touchUpdated && { updated: Date.now() }),
...(((updatedMessage.typing === false || !message.typing) && chatLLMId && {
tokenCount: countModelTokens(updatedMessage.text || message.text, chatLLMId, 'editMessage(typing=false)') ?? 0,
})),
};
}
return message;
});
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: touchUpdated ? Date.now() : conversation.updated,
};
}),
updateMetadata: (conversationId: string, messageId: string, metadataDelta: Partial<DMessageMetadata>, touchUpdated: boolean = true) => {
_get()._editConversation(conversationId, conversation => {
const messages = conversation.messages.map(message =>
message.id !== messageId ? message
: {
...message,
metadata: {
...message.metadata,
...metadataDelta,
},
updated: touchUpdated ? Date.now() : message.updated,
},
);
return {
messages,
updated: touchUpdated ? Date.now() : conversation.updated,
};
});
},
setSystemPurposeId: (conversationId: string, systemPurposeId: SystemPurposeId) =>
_get()._editConversation(conversationId,
{
systemPurposeId,
}),
setAutoTitle: (conversationId: string, autoTitle: string) =>
_get()._editConversation(conversationId,
{
autoTitle,
}),
setUserTitle: (conversationId: string, userTitle: string) =>
_get()._editConversation(conversationId,
{
userTitle,
}),
}),
{
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
* - 3: [2023-09-19] Switch to IndexedDB - no data shape change,
* but we swapped the backend (localStorage -> IndexedDB)
*/
version: 3,
storage: createJSONStorage(() => idbStateStorage),
// Migrations
migrate: (persistedState: unknown, _fromVersion: number): ConversationsStore => {
// other: just proceed
return persistedState as any;
},
// Pre-Saving: remove transient properties
partialize: (state) => ({
...state,
conversations: state.conversations.map((conversation: DConversation) => {
const {
abortController,
...rest
} = conversation;
return rest;
}),
}),
// Post-Loading: re-add transient properties and cleanup state
onRehydrateStorage: () => (state) => {
if (!state) return;
// fixup state
for (const conversation of (state.conversations || [])) {
// reset the typing flag
for (const message of conversation.messages)
message.typing = false;
// rehydrate the transient properties
conversation.abortController = null;
}
},
}),
{
name: 'AppChats',
enabled: false,
}),
);
export const conversationTitle = (conversation: DConversation, fallback?: string): string =>
conversation.userTitle || conversation.autoTitle || fallback || ''; // 👋💬🗨️
function getNextBranchTitle(currentTitle: string): string {
const numberPrefixRegex = /^\((\d+)\)\s+/; // Regex to find "(number) " at the beginning of the title
const match = currentTitle.match(numberPrefixRegex);
if (match) {
const number = parseInt(match[1], 10) + 1;
return currentTitle.replace(numberPrefixRegex, `(${number}) `);
} else
return `(1) ${currentTitle}`;
}
/**
* Convenience function to count the tokens in a DMessage object
*/
function updateDMessageTokenCount(message: DMessage, llmId: DLLMId | null, forceUpdate: boolean, debugFrom: string): number {
if (forceUpdate || !message.tokenCount)
message.tokenCount = llmId ? countModelTokens(message.text, llmId, debugFrom) ?? 0 : 0;
return message.tokenCount;
}
/**
* Convenience function to update a set of messages, using the current chatLLM
*/
function updateTokenCounts(messages: DMessage[], forceUpdate: boolean, debugFrom: string): number {
const chatLLMId = getChatLLMId();
return 3 + messages.reduce((sum, message) => 4 + updateDMessageTokenCount(message, chatLLMId, forceUpdate, debugFrom) + sum, 0);
}
export const getConversation = (conversationId: DConversationId | null): DConversation | null =>
conversationId ? useChatStore.getState().conversations.find(_c => _c.id === conversationId) ?? null : null;
export const getConversationSystemPurposeId = (conversationId: DConversationId | null): SystemPurposeId | null =>
getConversation(conversationId)?.systemPurposeId || null;
export const useConversation = (conversationId: DConversationId | null) => useChatStore(state => {
const { conversations } = state;
// this object will change if any sub-prop changes as well
const conversation = conversationId ? conversations.find(_c => _c.id === conversationId) ?? null : null;
const title = conversation ? conversationTitle(conversation) : null;
const isEmpty = conversation ? !conversation.messages.length : true;
const isDeveloper = conversation?.systemPurposeId === 'Developer';
const conversationIdx = conversation ? conversations.findIndex(_c => _c.id === conversation.id) : -1;
const hasConversations = conversations.length > 1 || (conversations.length === 1 && !!conversations[0].messages.length);
const recycleNewConversationId = (conversations.length && !conversations[0].messages.length) ? conversations[0].id : null;
return {
title,
isEmpty,
isDeveloper,
conversationIdx,
hasConversations,
recycleNewConversationId,
prependNewConversation: state.prependNewConversation,
branchConversation: state.branchConversation,
deleteConversations: state.deleteConversations,
};
}, shallow);
+1 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { v4 as uuidv4 } from 'uuid';
import type { DConversationId } from './store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
export interface DFolder {
+122
View File
@@ -0,0 +1,122 @@
import { v4 as uuidv4 } from 'uuid';
import { defaultSystemPurposeId, SystemPurposeId } from '../../../data';
import { DMessage, DMessageId, convertDMessage_V3_V4, duplicateDMessage } from './chat.message';
/// Conversation
export interface DConversation {
id: DConversationId; // unique identifier for this conversation
messages: DMessage[]; // linear list of messages in this conversation
// editable
userTitle?: string;
autoTitle?: string;
// TODO: @deprecated - this should be the system purpose of current head of the conversation
// there should be the concept of the audience of the current head
systemPurposeId: SystemPurposeId; // system purpose of this conversation
// TODO: @deprecated - should be a view-related cache
tokenCount: number; // f(messages, llmId)
// when updated is null, we don't have messages yet (timestamps as Date.now())
created: number; // creation timestamp
updated: number | null; // last update timestamp
// Not persisted, used while in-memory, or temporarily by the UI
// TODO: @deprecated - shouls not be in here - it's actually a per-message/operation thing
abortController: AbortController | null;
// future additions:
// draftUserMessage?: { text: string; attachments: any[] };
// isMuted: boolean; isArchived: boolean; isStarred: boolean;
// participants: personaIds...[];
}
export type DConversationId = string;
// helpers - creation
export function createDConversation(systemPurposeId?: SystemPurposeId): DConversation {
return {
id: uuidv4(),
messages: [],
// absent
// userTitle: undefined,
// autoTitle: undefined,
// @deprecated
systemPurposeId: systemPurposeId || defaultSystemPurposeId,
// @deprecated
tokenCount: 0,
created: Date.now(),
updated: Date.now(),
abortController: null,
};
}
export function duplicateCConversation(conversation: DConversation, lastMessageId?: DMessageId): DConversation {
// cut short messages, if requested
let messagesToKeep = conversation.messages.length; // By default, include all messages if messageId is null
if (lastMessageId) {
const messageIndex = conversation.messages.findIndex(_m => _m.id === lastMessageId);
if (messageIndex >= 0)
messagesToKeep = messageIndex + 1;
}
// auto-increment title (1)
const newTitle = getNextBranchTitle(conversationTitle(conversation));
return {
id: uuidv4(),
messages: conversation.messages
.slice(0, messagesToKeep)
.map(duplicateDMessage),
// userTitle: conversation.userTitle,
autoTitle: newTitle,
systemPurposeId: conversation.systemPurposeId,
tokenCount: conversation.tokenCount,
created: conversation.created,
updated: Date.now(),
abortController: null,
};
}
// helpers - conversion
export function convertCConversation_V3_V4(conversation: DConversation) {
conversation.messages.forEach(message => convertDMessage_V3_V4(message));
}
// helpers - title
export const conversationTitle = (conversation: DConversation, fallback?: string): string =>
conversation.userTitle || conversation.autoTitle || fallback || ''; // 👋💬🗨️
function getNextBranchTitle(currentTitle: string): string {
const numberPrefixRegex = /^\((\d+)\)\s+/; // Regex to find "(number) " at the beginning of the title
const match = currentTitle.match(numberPrefixRegex);
if (match) {
const number = parseInt(match[1], 10) + 1;
return currentTitle.replace(numberPrefixRegex, `(${number}) `);
} else
return `(1) ${currentTitle}`;
}
+220
View File
@@ -0,0 +1,220 @@
import { v4 as uuidv4 } from 'uuid';
import type { DBlobId } from '~/modules/dblobs/dblobs.types';
// Message
export interface DMessage {
id: DMessageId; // unique message ID
role: DMessageRole;
content: DContentPart[]; // multi-part content (sent: mix of text/images/etc., received: usually one part)
userAttachments: DAttachmentPart[]; // higher-level multi-part to be sent (transformed to multipart before sending)
// transient state
typing: boolean; // incomplete message, still typing - also suspends counting tokens while true
// identity
avatar: string | null; // image URL, or null
sender: 'You' | 'Bot' | string; // pretty name @deprecated
purposeId?: string; // only assistant/system
originLLM?: string; // only assistant - model that generated this message, goes beyond known models
metadata?: DMessageMetadata; // metadata, mainly at creation and for UI
userFlags?: DMessageUserFlag[]; // (UI) user-set per-message flags
// TODO: @deprecated remove this, it's really view-dependent
tokenCount: number; // cache for token count, using the current Conversation model (0 = not yet calculated)
created: number; // created timestamp
updated: number | null; // updated timestamp
}
export type DMessageId = string;
export type DMessageRole = 'user' | 'assistant' | 'system';
// Content Reference - we use a Ref and the DBlob framework to store media locally, or remote URLs
type DContentRef =
| { type: 'url'; url: string } // remotely accessible URL
| { type: 'dblob'; mimeType: string; dblobId: DBlobId } // reference to a DBlob
;
// type CMediaSourceInline =
// | { type: 'base64'; mimeType: string; base64Data: string }
// ;
// Content Part
export type DContentPart =
| { type: 'text'; text: string }
| { type: 'image'; mimeType: string; source: DContentRef }
// | { type: 'audio'; mimeType: string; source: DContentRef }
// | { type: 'video'; mimeType: string; source: DContentRef }
// | { type: 'document'; source: DContentRef }
| { type: 'function_call'; function: string; args: Record<string, any> }
| { type: 'function_response'; function: string; response: Record<string, any> }
;
// Attachment Part
export type DAttachmentPart =
| { type: 'atext', text: string, title?: string, collapsible: boolean }
| { type: 'aimage', source: DContentRef, title?: string, width?: number, height?: number, collapsible: false }
// export type CAttachmentMultiPart = CAttachmentPart[];
// Metadata
export interface DMessageMetadata {
inReplyToText?: string; // text this was in reply to
}
// User Flags
export type DMessageUserFlag =
| 'starred'; // user starred this
// helpers - creation
export function createDMessage(role: DMessageRole, text: string): DMessage {
return {
id: uuidv4(),
role: role,
content: [createTextPart(text)],
userAttachments: [],
// transient
typing: false,
// identity
avatar: null,
sender: role === 'user' ? 'You' : 'Bot',
// absent
// purposeId: undefined,
// originLLM: undefined,
// metadata: undefined,
// userFlags: undefined,
// @deprecated
tokenCount: 0,
// when updated is null, the message is considered incomplete yet (probably still typing)
created: Date.now(),
updated: null,
};
}
export function createTextPart(text: string): DContentPart {
return { type: 'text', text };
}
export function duplicateDMessage(message: DMessage): DMessage {
// TODO: deep copy of content and userAttachments?
// there may be refs to the same DBlob, but that's fine, hopefully (they are immutable once here)
// the dblob may need more reference count?
return {
id: uuidv4(),
role: message.role,
content: message.content.map(part => ({ ...part })),
userAttachments: message.userAttachments.map(part => ({ ...part })),
typing: false,
avatar: message.avatar,
sender: message.sender,
purposeId: message.purposeId,
originLLM: message.originLLM,
metadata: message.metadata ? { ...message.metadata } : undefined,
userFlags: message.userFlags ? [...message.userFlags] : undefined,
tokenCount: message.tokenCount,
created: message.created,
updated: message.updated,
};
}
// helpers - conversion
export function convertDMessage_V3_V4(message: DMessage) {
const v3 = message as DMessage & { text: string };
if (!message.content || message.content.length === 0) {
message.content = [createTextPart(v3.text || '')];
delete (v3 as any).text;
}
if (!message.userAttachments?.length)
message.userAttachments = [];
}
// helpers - text
export function singleTextOrThrow(message: DMessage): string {
if (message.content.length !== 1)
throw new Error('Expected single content');
if (message.content[0].type !== 'text')
throw new Error('Expected text content');
return message.content[0].text;
}
export function singleTextOrThrow2(content?: DContentPart[]): string {
if (!content || content.length !== 1)
throw new Error('Expected single content');
if (content[0].type !== 'text')
throw new Error('Expected text content');
return content[0].text;
}
// zustand-like deep replace
export function contentPartsReplaceText(message: DMessage, newText: string): DContentPart[] {
const lastTextPart = message.content.findLast(part => part.type === 'text');
if (!lastTextPart)
return [...message.content, createTextPart(newText)];
return message.content.map(part =>
(part === lastTextPart)
? { ...part, text: newText }
: part,
);
}
export function fixmeThisReplacesAllParts(text: string): DContentPart[] {
return [createTextPart(text)];
}
// helpers - user flags
const flag2EmojiMap: Record<DMessageUserFlag, string> = {
starred: '⭐️',
};
export function messageUserFlagToEmoji(flag: DMessageUserFlag): string {
return flag2EmojiMap[flag] || '❓';
}
export function messageHasUserFlag(message: DMessage, flag: DMessageUserFlag): boolean {
return message.userFlags?.includes(flag) ?? false;
}
export function messageToggleUserFlag(message: DMessage, flag: DMessageUserFlag): DMessageUserFlag[] {
if (message.userFlags?.includes(flag))
return message.userFlags.filter(_f => _f !== flag);
else
return [...(message.userFlags || []), flag];
}
+374
View File
@@ -0,0 +1,374 @@
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
import { v4 as uuidv4 } from 'uuid';
import { DLLMId, getChatLLMId } from '~/modules/llms/store-llms';
import { SystemPurposeId } from '../../../data';
import { countModelTokens } from '../../util/token-counter';
import { idbStateStorage } from '../../util/idbUtils';
import { DConversation, DConversationId, conversationTitle, convertCConversation_V3_V4, createDConversation, duplicateCConversation } from './chat.conversation';
import { DMessage, DMessageId, DMessageMetadata } from './chat.message';
/// Conversations Store
interface ChatState {
conversations: DConversation[];
}
export interface ChatActions {
// CRUD conversations
prependNewConversation: (personaId: SystemPurposeId | undefined) => DConversationId;
importConversation: (c: DConversation, preventClash: boolean) => DConversationId;
branchConversation: (cId: DConversationId, mId: DMessageId | null) => DConversationId | null;
deleteConversations: (cIds: DConversationId[], newConversationPersonaId?: SystemPurposeId) => DConversationId;
// within a conversation
setAbortController: (cId: DConversationId, abortController: AbortController | null) => void;
abortTyping: (cId: DConversationId) => void;
setMessages: (cId: DConversationId, messages: DMessage[]) => void;
appendMessage: (cId: DConversationId, message: DMessage) => void;
deleteMessage: (cId: DConversationId, mId: DMessageId) => void;
editMessage: (cId: DConversationId, mId: DMessageId, update: Partial<DMessage> | ((message: DMessage) => Partial<DMessage>), touchUpdated: boolean) => void;
updateMetadata: (cId: DConversationId, mId: DMessageId, metadataDelta: Partial<DMessageMetadata>, touchUpdated?: boolean) => void;
setSystemPurposeId: (cId: DConversationId, personaId: SystemPurposeId) => void;
setAutoTitle: (cId: DConversationId, autoTitle: string) => void;
setUserTitle: (cId: DConversationId, userTitle: string) => void;
// utility function
_editConversation: (cId: DConversationId, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) => void;
}
type ConversationsStore = ChatState & ChatActions;
const defaultConversations: DConversation[] = [createDConversation()];
export const useChatStore = create<ConversationsStore>()(devtools(
persist(
(_set, _get) => ({
// default state
conversations: defaultConversations,
prependNewConversation: (personaId: SystemPurposeId | undefined): DConversationId => {
const newConversation = createDConversation(personaId);
_set(state => ({
conversations: [newConversation, ...state.conversations],
}));
return newConversation.id;
},
importConversation: (conversation: DConversation, preventClash: boolean): DConversationId => {
const { conversations } = _get();
// if there's a clash, abort the former conversation, and optionally change the ID
const existing = conversations.find(_c => _c.id === conversation.id);
if (existing) {
existing?.abortController?.abort();
if (preventClash) {
conversation.id = uuidv4();
console.warn('Conversation ID clash, changing ID to', conversation.id);
}
}
conversation.tokenCount = updateMessagesTokenCounts(conversation.messages, true, 'importConversation');
_set({
conversations: [conversation, ...conversations.filter(_c => _c.id !== conversation.id)],
});
return conversation.id;
},
branchConversation: (conversationId: DConversationId, messageId: DMessageId | null): DConversationId | null => {
const { conversations } = _get();
const conversation = conversations.find(_c => _c.id === conversationId);
if (!conversation)
return null;
const branched = duplicateCConversation(conversation, messageId ?? undefined);
_set({
conversations: [branched, ...conversations],
});
return branched.id;
},
deleteConversations: (conversationIds: DConversationId[], newConversationPersonaId?: SystemPurposeId): DConversationId => {
const { conversations } = _get();
// find the index of first conversation to delete
const cIndex = conversationIds.length > 0 ? conversations.findIndex(_c => _c.id === conversationIds[0]) : -1;
// abort all pending requests
conversationIds.forEach(conversationId => conversations.find(_c => _c.id === conversationId)?.abortController?.abort());
// remove from the list
const newConversations = conversations.filter(_c => !conversationIds.includes(_c.id));
// create a new conversation if there are no more
if (!newConversations.length)
newConversations.push(createDConversation(newConversationPersonaId));
_set({
conversations: newConversations,
});
// return the next conversation Id in line, if valid
return newConversations[(cIndex >= 0 && cIndex < newConversations.length) ? cIndex : 0].id;
},
// within a conversation
_editConversation: (conversationId: DConversationId, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) =>
_set(state => ({
conversations: state.conversations.map((conversation): DConversation =>
conversation.id === conversationId
? {
...conversation,
...(typeof update === 'function' ? update(conversation) : update),
}
: conversation,
),
})),
setAbortController: (conversationId: DConversationId, abortController: AbortController | null) =>
_get()._editConversation(conversationId, () =>
({
abortController: abortController,
})),
abortTyping: (conversationId: DConversationId) =>
_get()._editConversation(conversationId, conversation => {
conversation.abortController?.abort();
return {
abortController: null,
};
}),
setMessages: (conversationId: DConversationId, newMessages: DMessage[]) =>
_get()._editConversation(conversationId, conversation => {
conversation.abortController?.abort();
return {
messages: newMessages,
...(!!newMessages.length ? {} : {
autoTitle: undefined,
}),
tokenCount: updateMessagesTokenCounts(newMessages, false, 'setMessages'),
updated: Date.now(),
abortController: null,
};
}),
appendMessage: (conversationId: DConversationId, message: DMessage) =>
_get()._editConversation(conversationId, conversation => {
if (!message.typing)
updateMessagesTokenCounts([message], true, 'appendMessage');
const messages = [...conversation.messages, message];
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: Date.now(),
};
}),
deleteMessage: (conversationId: DConversationId, messageId: DMessageId) =>
_get()._editConversation(conversationId, conversation => {
const messages = conversation.messages.filter(message => message.id !== messageId);
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: Date.now(),
};
}),
editMessage: (conversationId: DConversationId, messageId: DMessageId, update: Partial<DMessage> | ((message: DMessage) => Partial<DMessage>), touchUpdated: boolean) =>
_get()._editConversation(conversationId, conversation => {
const messages = conversation.messages.map((message): DMessage => {
if (message.id !== messageId)
return message;
const updatedMessage: DMessage = {
...message,
...(typeof update === 'function' ? update(message) : update),
...(touchUpdated && { updated: Date.now() }),
};
if (!updatedMessage.typing)
updateMessageTokenCount(updatedMessage, getChatLLMId(), true, 'editMessage(typing=false)');
return updatedMessage;
});
return {
messages,
tokenCount: messages.reduce((sum, message) => sum + 4 + message.tokenCount || 0, 3),
updated: touchUpdated ? Date.now() : conversation.updated,
};
}),
updateMetadata: (conversationId: DConversationId, messageId: DMessageId, metadataDelta: Partial<DMessageMetadata>, touchUpdated: boolean = true) => {
_get()._editConversation(conversationId, conversation => {
const messages = conversation.messages.map(message =>
message.id !== messageId ? message
: {
...message,
metadata: {
...message.metadata,
...metadataDelta,
},
updated: touchUpdated ? Date.now() : message.updated,
},
);
return {
messages,
updated: touchUpdated ? Date.now() : conversation.updated,
};
});
},
setSystemPurposeId: (conversationId: DConversationId, personaId: SystemPurposeId) =>
_get()._editConversation(conversationId,
{
systemPurposeId: personaId,
}),
setAutoTitle: (conversationId: DConversationId, autoTitle: string) =>
_get()._editConversation(conversationId,
{
autoTitle,
}),
setUserTitle: (conversationId: DConversationId, userTitle: string) =>
_get()._editConversation(conversationId,
{
userTitle,
}),
}),
{
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
* - 3: [2023-09-19] Switch to IndexedDB - no data shape change,
* but we swapped the backend (localStorage -> IndexedDB)
* - 4: [2024-05-14] Convert messages to multi-part, removed the IDB migration
*/
version: 4,
storage: createJSONStorage(() => idbStateStorage),
// Migrations
migrate: (state: any, fromVersion: number): ConversationsStore => {
// 3 -> 4: Convert messages to multi-part
if (fromVersion < 4 && state && state.conversations && state.conversations.length)
state.conversations.forEach(convertCConversation_V3_V4);
return state;
},
// Pre-Saving: remove transient properties
partialize: (state) => ({
...state,
conversations: state.conversations.map((conversation: DConversation) => {
const { abortController, ...rest } = conversation;
return rest;
}),
}),
// Post-Loading: re-add transient properties and cleanup state
onRehydrateStorage: () => (state) => {
if (!state) return;
// fixup conversations
for (const conversation of (state.conversations || [])) {
// re-add transient properties
conversation.abortController = null;
// fixup messages
for (const message of conversation.messages)
message.typing = false;
}
},
}),
{
name: 'AppChats',
enabled: false,
}),
);
// Convenience function to update a set of messages, using the current chatLLM
function updateMessagesTokenCounts(messages: DMessage[], forceUpdate: boolean, debugFrom: string): number {
const chatLLMId = getChatLLMId();
return 3 + messages.reduce((sum, message) => {
return 4 + updateMessageTokenCount(message, chatLLMId, forceUpdate, debugFrom) + sum;
}, 0);
}
// Convenience function to count the tokens in a DMessage object
function updateMessageTokenCount(message: DMessage, llmId: DLLMId | null, forceUpdate: boolean, debugFrom: string): number {
if (forceUpdate || !message.tokenCount) {
if (!llmId) {
message.tokenCount = 0;
return 0;
}
// NOTE: temporary flattening of text-only parts, until we figure out a better way to handle this
// FIXME: this is a quick and dirty hack, until we move token counting outside
const messageTextParts = message.content.reduce((fullText, part) => fullText + (part.type === 'text' ? part.text : ''), '');
// TODO: handle attachments too
message.tokenCount = countModelTokens(messageTextParts, llmId, debugFrom) ?? 0;
}
return message.tokenCount;
}
export const getConversation = (conversationId: DConversationId | null): DConversation | null =>
conversationId ? useChatStore.getState().conversations.find(_c => _c.id === conversationId) ?? null : null;
export const getConversationSystemPurposeId = (conversationId: DConversationId | null): SystemPurposeId | null =>
getConversation(conversationId)?.systemPurposeId || null;
export const useConversation = (conversationId: DConversationId | null) => useChatStore(useShallow(state => {
const { conversations } = state;
// this object will change if any sub-prop changes as well
const conversation = conversationId ? conversations.find(_c => _c.id === conversationId) ?? null : null;
const title = conversation ? conversationTitle(conversation) : null;
const isEmpty = conversation ? !conversation.messages.length : true;
const isDeveloper = conversation?.systemPurposeId === 'Developer';
const conversationIdx = conversation ? conversations.findIndex(_c => _c.id === conversation.id) : -1;
const hasConversations = conversations.length > 1 || (conversations.length === 1 && !!conversations[0].messages.length);
const recycleNewConversationId = (conversations.length && !conversations[0].messages.length) ? conversations[0].id : null;
return {
title,
isEmpty,
isDeveloper,
conversationIdx,
hasConversations,
recycleNewConversationId,
prependNewConversation: state.prependNewConversation,
branchConversation: state.branchConversation,
deleteConversations: state.deleteConversations,
};
}));
@@ -1,7 +1,8 @@
import { llmChatGenerateOrThrow, VChatFunctionIn } from '~/modules/llms/llm.client';
import { useModelsStore } from '~/modules/llms/store-llms';
import { useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { createTextPart, singleTextOrThrow } from '~/common/stores/chat/chat.message';
/*const suggestUserFollowUpFn: VChatFunctionIn = {
@@ -67,7 +68,7 @@ export function autoSuggestions(conversationId: string, assistantMessageId: stri
// Execute the following follow-ups in parallel
// const assistantMessageId = assistantMessage.id;
let assistantMessageText = assistantMessage.text;
let assistantMessageText = singleTextOrThrow(assistantMessage);
// Follow-up: Question
if (suggestQuestions) {
@@ -84,8 +85,8 @@ export function autoSuggestions(conversationId: string, assistantMessageId: stri
// Follow-up: Auto-Diagrams
if (suggestDiagrams) {
void llmChatGenerateOrThrow(funcLLMId, [
{ role: 'system', content: systemMessage.text },
{ role: 'user', content: userMessage.text },
{ role: 'system', content: singleTextOrThrow(systemMessage) },
{ role: 'user', content: singleTextOrThrow(userMessage) },
{ role: 'assistant', content: assistantMessageText },
], [suggestPlantUMLFn], 'draw_plantuml_diagram',
).then(chatResponse => {
@@ -105,7 +106,7 @@ export function autoSuggestions(conversationId: string, assistantMessageId: stri
// append the PlantUML diagram to the assistant response
editMessage(conversationId, assistantMessageId, {
text: assistantMessageText + `\n\n\`\`\`${type}.diagram\n${plantUML}\n\`\`\`\n`,
content: [createTextPart(assistantMessageText + `\n\n\`\`\`${type}.diagram\n${plantUML}\n\`\`\`\n`)],
}, false);
}
}
+4 -2
View File
@@ -1,7 +1,8 @@
import { getFastLLMId } from '~/modules/llms/store-llms';
import { llmChatGenerateOrThrow } from '~/modules/llms/llm.client';
import { useChatStore } from '~/common/state/store-chats';
import { singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { useChatStore } from '~/common/stores/chat/store-chats';
/**
@@ -26,7 +27,8 @@ export async function conversationAutoTitle(conversationId: string, forceReplace
// first line of the last 5 messages
const historyLines: string[] = conversation.messages.filter(m => m.role !== 'system').slice(-5).map(m => {
let text = m.text.split('\n')[0];
const messageText = singleTextOrThrow(m);
let text = messageText.split('\n')[0];
text = text.length > 50 ? text.substring(0, 50) + '...' : text;
text = `${m.role === 'user' ? 'You' : 'Assistant'}: ${text}`;
return `- ${text}`;
+4 -2
View File
@@ -15,7 +15,8 @@ import { llmStreamingChatGenerate } from '~/modules/llms/llm.client';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineError } from '~/common/components/InlineError';
import { adjustContentScaling } from '~/common/app.theme';
import { createDMessage, useChatStore } from '~/common/state/store-chats';
import { createDMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFormRadio } from '~/common/components/forms/useFormRadio';
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
import { useIsMobile } from '~/common/components/useMatchMedia';
@@ -95,7 +96,8 @@ export function DiagramsModal(props: { config: DiagramConfig, onClose: () => voi
const stepAbortController = new AbortController();
setAbortController(stepAbortController);
const diagramPrompt = bigDiagramPrompt(diagramType, diagramLanguage, systemMessage.text, subject, customInstruction);
const systemMessageText = singleTextOrThrow(systemMessage);
const diagramPrompt = bigDiagramPrompt(diagramType, diagramLanguage, systemMessageText, subject, customInstruction);
try {
await llmStreamingChatGenerate(diagramLlm.id, diagramPrompt, null, null, stepAbortController.signal,
+5 -2
View File
@@ -7,9 +7,11 @@ import ReplayIcon from '@mui/icons-material/Replay';
import { useStreamChatText } from '~/modules/aifn/useStreamChatText';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { DMessage, createDMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
import { FLATTEN_PROFILES, FlattenStyleType } from './flatten.data';
@@ -68,7 +70,8 @@ function encodeConversationAsUserMessage(userPrompt: string, messages: DMessage[
for (const message of messages) {
if (message.role === 'system') continue;
const author = message.role === 'user' ? 'User' : 'Assistant';
const text = message.text.replace(/\n/g, '\n\n');
const messageText = singleTextOrThrow(message);
const text = messageText.replace(/\n/g, '\n\n');
encodedMessages += `---${author}---\n${text}\n\n`;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { createDMessage, DMessage } from '~/common/state/store-chats';
import { DMessage, createDMessage } from '~/common/stores/chat/chat.message';
const replyToSystemPrompt = `The user is referring to this in particular:
+1 -1
View File
@@ -4,7 +4,7 @@ import type { StateCreator } from 'zustand/vanilla';
import type { DLLMId } from '~/modules/llms/store-llms';
import type { DMessage } from '~/common/state/store-chats';
import type { DMessage } from '~/common/stores/chat/chat.message';
import { CUSTOM_FACTORY_ID, FFactoryId, findFusionFactory, FUSION_FACTORIES, FUSION_FACTORY_DEFAULT } from './instructions/beam.gather.factories';
import { GATHER_PLACEHOLDER } from '../beam.config';
@@ -8,7 +8,7 @@ import { streamAssistantMessage } from '../../../../apps/chat/editors/chat-strea
import type { VChatMessageIn } from '~/modules/llms/llm.client';
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { DMessage } from '~/common/state/store-chats';
import { DMessage, createTextPart, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import type { BaseInstruction, ExecutionInputState } from './beam.gather.execution';
@@ -48,17 +48,17 @@ export async function executeChatGenerate(_i: ChatGenerateInstruction, inputs: E
// s0-h0-u0
...inputs.chatMessages
.filter((m) => (m.role === 'user' || m.role === 'assistant'))
.map((m): VChatMessageIn => ({ role: (m.role !== 'assistant') ? 'user' : m.role, content: m.text })),
.map((m): VChatMessageIn => ({ role: (m.role !== 'assistant') ? 'user' : m.role, content: singleTextOrThrow(m) })),
// aN
...inputs.rayMessages
.map((m): VChatMessageIn => ({ role: 'assistant', content: m.text })),
.map((m): VChatMessageIn => ({ role: 'assistant', content: singleTextOrThrow(m) })),
// u
{ role: 'user', content: _mixChatGeneratePrompt(_i.userPrompt, inputs.rayMessages.length, prevStepOutput) },
];
// reset the intermediate message
Object.assign(inputs.intermediateDMessage, {
text: GATHER_PLACEHOLDER,
content: [createTextPart(GATHER_PLACEHOLDER)],
updated: undefined,
} satisfies Partial<DMessage>);
@@ -66,8 +66,8 @@ export async function executeChatGenerate(_i: ChatGenerateInstruction, inputs: E
const onMessageUpdate = (update: Partial<DMessage>) => {
// in-place update of the intermediate message
Object.assign(inputs.intermediateDMessage, update);
if (update.text)
inputs.intermediateDMessage.updated = Date.now();
// if (update.text) // TODO: port to contents
inputs.intermediateDMessage.updated = Date.now();
switch (_i.display) {
case 'mute':
@@ -75,7 +75,7 @@ export async function executeChatGenerate(_i: ChatGenerateInstruction, inputs: E
case 'character-count':
inputs.updateInstructionComponent(
<Typography level='body-xs' sx={{ opacity: 0.5 }}>{update.text?.length || 0} characters</Typography>,
<Typography level='body-xs' sx={{ opacity: 0.5 }}>{singleTextOrThrow(update as any)?.length || 0} characters</Typography>,
);
return;
@@ -107,7 +107,7 @@ export async function executeChatGenerate(_i: ChatGenerateInstruction, inputs: E
throw new Error(`Model execution error: ${status.errorMessage || 'Unknown error'}`);
// Proceed to the next step
return inputs.intermediateDMessage.text;
return singleTextOrThrow(inputs.intermediateDMessage);
});
}
@@ -3,7 +3,7 @@ import { Typography } from '@mui/joy';
import type { DLLMId } from '~/modules/llms/store-llms';
import { createDMessage, type DMessage } from '~/common/state/store-chats';
import { DMessage, createDMessage } from '~/common/stores/chat/chat.message';
import type { BFusion, FusionUpdateOrFn } from '../beam.gather';
import { ChatGenerateInstruction, executeChatGenerate } from './ChatGenerateInstruction';
@@ -5,7 +5,7 @@ import { Box, Typography } from '@mui/joy';
import { ChatMessageMemo } from '../../../apps/chat/components/message/ChatMessage';
import type { DMessage } from '~/common/state/store-chats';
import type { DMessage } from '~/common/stores/chat/chat.message';
import { BEAM_INVERT_BACKGROUND } from '../beam.config';
import { useModuleBeamStore } from '../store-module-beam';
+3 -3
View File
@@ -6,7 +6,7 @@ import { streamAssistantMessage } from '../../../apps/chat/editors/chat-stream';
import type { DLLMId } from '~/modules/llms/store-llms';
import type { VChatMessageIn } from '~/modules/llms/llm.client';
import { createDMessage, DMessage } from '~/common/state/store-chats';
import { createDMessage, DMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import type { RootStoreSlice } from '../store-beam-vanilla';
@@ -228,9 +228,9 @@ export const createScatterSlice: StateCreator<RootStoreSlice & ScatterStoreSlice
// Note: message.originLLM misss the prefix (e.g. gpt-4-0125 wihtout 'openai-..') so it won't match here
const ray = createBRay(raysLlmId);
// pre-fill the ray status with the message and to a successful state
if (message.text.trim()) {
if (singleTextOrThrow(message).trim()) {
ray.status = 'success';
ray.message.text = message.text;
ray.message.content = [...message.content];
ray.message.updated = Date.now();
ray.imported = true;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { createStore, StateCreator } from 'zustand/vanilla';
import { DLLMId, getDiverseTopLlmIds } from '~/modules/llms/store-llms';
import type { DMessage } from '~/common/state/store-chats';
import { DMessage } from '~/common/stores/chat/chat.message';
import { BeamConfigSnapshot, useModuleBeamStore } from './store-module-beam';
import { SCATTER_RAY_DEF } from './beam.config';
+2 -2
View File
@@ -7,7 +7,7 @@ import { Box, Button, Tooltip, Typography } from '@mui/joy';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import type { DMessage } from '~/common/state/store-chats';
import { DMessageRole } from '~/common/stores/chat/chat.message';
import { ContentScaling, lineHeightChatTextMd, themeScalingMap } from '~/common/app.theme';
import { InlineError } from '~/common/components/InlineError';
@@ -50,7 +50,7 @@ const renderBlocksSx: SxProps = {
type BlocksRendererProps = {
// required
text: string;
fromRole: DMessage['role'];
fromRole: DMessageRole;
contentScaling: ContentScaling;
renderTextAsMarkdown: boolean;
renderTextDiff?: TextDiff[];
+3 -1
View File
@@ -81,7 +81,7 @@ type ItemDataOrigin = UploadOrigin | GeneratedOrigin;
// Item Base type
interface DBlobBase<TType extends DBlobMetaDataType, TMime extends DBlobMimeType, TMeta extends Record<string, any>> {
id: string; // Unique identifier
id: DBlobId; // Unique identifier
type: TType; // Type of item, used for discrimination
label: string; // Textual representation
@@ -95,6 +95,8 @@ interface DBlobBase<TType extends DBlobMetaDataType, TMime extends DBlobMimeType
cache: Record<string, DBlobData<DBlobMimeType>>; // Cached conversions as BlobData objects
}
export type DBlobId = string;
export function createDBlobBase<TType extends DBlobMetaDataType, TMime extends DBlobMimeType, TMeta extends Record<string, any>>(type: TType, label: string, data: DBlobData<TMime>, origin: ItemDataOrigin, metadata: TMeta): DBlobBase<TType, TMime, TMeta> {
return {
id: uuidv4(),
+2 -1
View File
@@ -6,9 +6,10 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities';
import { DConversationId, getConversation } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { KeyStroke } from '~/common/components/KeyStroke';
import { getConversation } from '~/common/stores/chat/store-chats';
import { ChatLinkExport } from './link/ChatLinkExport';
import { PublishExport } from './publish/PublishExport';
+3 -1
View File
@@ -4,13 +4,15 @@ import { Box, Button, FormControl, Input, Sheet, Textarea, Typography } from '@m
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { Brand } from '~/common/app.config';
import { DConversationId, createDConversation } from '~/common/stores/chat/chat.conversation';
import { DMessage, createDMessage } from '~/common/stores/chat/chat.message';
import { FormRadioOption } from '~/common/components/forms/FormRadioControl';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { InlineError } from '~/common/components/InlineError';
import { KeyStroke } from '~/common/components/KeyStroke';
import { OpenAIIcon } from '~/common/components/icons/vendors/OpenAIIcon';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { createDConversation, createDMessage, DConversationId, DMessage, useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFormRadio } from '~/common/components/forms/useFormRadio';
import type { ChatGptSharedChatSchema } from './server/chatgpt';
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { Alert, Box, Divider, IconButton, List, ListItem, Tooltip, Typography } from '@mui/joy';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import type { DConversation, DConversationId } from '~/common/state/store-chats';
import type { DConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
import { GoodModal } from '~/common/components/GoodModal';
import { copyToClipboard } from '~/common/util/clipboardUtils';
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { GoodModal } from '~/common/components/GoodModal';
import { ExportChats, ExportConfig } from './ExportChats';
+2 -1
View File
@@ -5,11 +5,12 @@ import DoneIcon from '@mui/icons-material/Done';
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
import { Brand } from '~/common/app.config';
import { DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { Link } from '~/common/components/Link';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { conversationTitle, DConversationId, getConversation } from '~/common/state/store-chats';
import { getConversation } from '~/common/stores/chat/store-chats';
import type { StoragePutSchema, StorageUpdateDeletionKeySchema } from '../server/link';
import { ChatLinkDetails } from './ChatLinkDetails';
+2 -1
View File
@@ -5,11 +5,12 @@ import ExitToAppIcon from '@mui/icons-material/ExitToApp';
import { getChatShowSystemMessages } from '../../../apps/chat/store-app-chat';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { DConversationId, getConversation } from '~/common/state/store-chats';
import { Link } from '~/common/components/Link';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { getConversation } from '~/common/stores/chat/store-chats';
import { isBrowser } from '~/common/util/pwaUtils';
import type { PublishedSchema } from '../server/pastegg';
+5 -3
View File
@@ -5,11 +5,13 @@ import { defaultSystemPurposeId, SystemPurposeId, SystemPurposes } from '../../d
import { DModelSource, useModelsStore } from '~/modules/llms/store-llms';
import { Brand } from '~/common/app.config';
import { DConversation, DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { DMessage, singleTextOrThrow } from '~/common/stores/chat/chat.message';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle, DConversation, type DConversationId, DMessage, useChatStore } from '~/common/state/store-chats';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { prettyTimestampForFilenames } from '~/common/util/timeUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { ImportedOutcome } from './ImportOutcomeModal';
@@ -205,7 +207,7 @@ export function conversationToMarkdown(conversation: DConversation, hideSystemMe
: '';
return mdTitle + conversation.messages.filter(message => !hideSystemMessage || message.role !== 'system').map(message => {
let sender: string = message.sender;
let text = message.text;
let text = singleTextOrThrow(message);
switch (message.role) {
case 'system':
sender = '✨ System message';
@@ -215,7 +217,7 @@ export function conversationToMarkdown(conversation: DConversation, hideSystemMe
const purpose = message.purposeId || conversation.systemPurposeId || null;
sender = `${purpose || 'Assistant'} · *${prettyBaseModel(message.originLLM || '')}*`.trim();
if (purpose && purpose in SystemPurposes)
sender = `${SystemPurposes[purpose]?.symbol || ''} ${sender}`.trim();
sender = `${SystemPurposes[purpose as SystemPurposeId]?.symbol || ''} ${sender}`.trim();
break;
case 'user':
sender = '👤 You';