diff --git a/pages/info/debug.tsx b/pages/info/debug.tsx index 43fafee01..a822e7b1f 100644 --- a/pages/info/debug.tsx +++ b/pages/info/debug.tsx @@ -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'; diff --git a/src/apps/beam/AppBeam.tsx b/src/apps/beam/AppBeam.tsx index 5d887588f..c2e44cdc7 100644 --- a/src/apps/beam/AppBeam.tsx +++ b/src/apps/beam/AppBeam.tsx @@ -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'; diff --git a/src/apps/call/AppCall.tsx b/src/apps/call/AppCall.tsx index 43f3851c5..c415561ad 100644 --- a/src/apps/call/AppCall.tsx +++ b/src/apps/call/AppCall.tsx @@ -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'; diff --git a/src/apps/call/CallWizard.tsx b/src/apps/call/CallWizard.tsx index eb7c2df5a..f3ff75f0b 100644 --- a/src/apps/call/CallWizard.tsx +++ b/src/apps/call/CallWizard.tsx @@ -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'; diff --git a/src/apps/call/Contacts.tsx b/src/apps/call/Contacts.tsx index c9abd8797..c23d24be4 100644 --- a/src/apps/call/Contacts.tsx +++ b/src/apps/call/Contacts.tsx @@ -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, 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 diff --git a/src/apps/call/Telephone.tsx b/src/apps/call/Telephone.tsx index 741a33ee5..cd1f45b48 100644 --- a/src/apps/call/Telephone.tsx +++ b/src/apps/call/Telephone.tsx @@ -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) => { + 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.', diff --git a/src/apps/chat/components/ChatBarAltTitle.tsx b/src/apps/chat/components/ChatBarAltTitle.tsx index 3fbae8a51..08bc0bd0c 100644 --- a/src/apps/chat/components/ChatBarAltTitle.tsx +++ b/src/apps/chat/components/ChatBarAltTitle.tsx @@ -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'; diff --git a/src/apps/chat/components/ChatBarDropdowns.tsx b/src/apps/chat/components/ChatBarDropdowns.tsx index 692081615..aa0886f50 100644 --- a/src/apps/chat/components/ChatBarDropdowns.tsx +++ b/src/apps/chat/components/ChatBarDropdowns.tsx @@ -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'; diff --git a/src/apps/chat/components/ChatDrawer.tsx b/src/apps/chat/components/ChatDrawer.tsx index ccc998411..23443254a 100644 --- a/src/apps/chat/components/ChatDrawer.tsx +++ b/src/apps/chat/components/ChatDrawer.tsx @@ -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'; diff --git a/src/apps/chat/components/ChatDrawerItem.tsx b/src/apps/chat/components/ChatDrawerItem.tsx index b99d9f17a..9839a2d09 100644 --- a/src/apps/chat/components/ChatDrawerItem.tsx +++ b/src/apps/chat/components/ChatDrawerItem.tsx @@ -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'; diff --git a/src/apps/chat/components/ChatMessageList.tsx b/src/apps/chat/components/ChatMessageList.tsx index 407b39d23..c3aefbfb2 100644 --- a/src/apps/chat/components/ChatMessageList.tsx +++ b/src/apps/chat/components/ChatMessageList.tsx @@ -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 => ({ + 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 }; diff --git a/src/apps/chat/components/ChatPageMenuItems.tsx b/src/apps/chat/components/ChatPageMenuItems.tsx index 87d24da18..292d0065e 100644 --- a/src/apps/chat/components/ChatPageMenuItems.tsx +++ b/src/apps/chat/components/ChatPageMenuItems.tsx @@ -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'; diff --git a/src/apps/chat/components/Ephemerals.tsx b/src/apps/chat/components/Ephemerals.tsx index cfcc68eac..348e1b044 100644 --- a/src/apps/chat/components/Ephemerals.tsx +++ b/src/apps/chat/components/Ephemerals.tsx @@ -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'; diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index fa519c137..67c6bd737 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -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]); diff --git a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx index d77bfcf11..b301efec8 100644 --- a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx +++ b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx @@ -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); diff --git a/src/apps/chat/components/composer/composer.types.ts b/src/apps/chat/components/composer/composer.types.ts deleted file mode 100644 index 425fda58f..000000000 --- a/src/apps/chat/components/composer/composer.types.ts +++ /dev/null @@ -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[]; diff --git a/src/apps/chat/components/folders/useFolderDropdown.tsx b/src/apps/chat/components/folders/useFolderDropdown.tsx index d38bd9db4..5e7c56ef8 100644 --- a/src/apps/chat/components/folders/useFolderDropdown.tsx +++ b/src/apps/chat/components/folders/useFolderDropdown.tsx @@ -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'; diff --git a/src/apps/chat/components/message/ChatMessage.tsx b/src/apps/chat/components/message/ChatMessage.tsx index b26f728ba..d554a3b20 100644 --- a/src/apps/chat/components/message/ChatMessage.tsx +++ b/src/apps/chat/components/message/ChatMessage.tsx @@ -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 ; @@ -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 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; } diff --git a/src/apps/chat/components/usePersonaDropdown.tsx b/src/apps/chat/components/usePersonaDropdown.tsx index dfee583a5..f2988d0a3 100644 --- a/src/apps/chat/components/usePersonaDropdown.tsx +++ b/src/apps/chat/components/usePersonaDropdown.tsx @@ -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'; diff --git a/src/apps/chat/editors/_handleExecute.tsx b/src/apps/chat/editors/_handleExecute.tsx index d7e74dc45..e0169c0ec 100644 --- a/src/apps/chat/editors/_handleExecute.tsx +++ b/src/apps/chat/editors/_handleExecute.tsx @@ -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); 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 diff --git a/src/apps/chat/editors/chat-stream.ts b/src/apps/chat/editors/chat-stream.ts index 5b19bc961..397592496 100644 --- a/src/apps/chat/editors/chat-stream.ts +++ b/src/apps/chat/editors/chat-stream.ts @@ -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 = { text: '' }; + const incrementalAnswer: Partial = { 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; } \ No newline at end of file diff --git a/src/apps/link-chat/AppLinkChat.tsx b/src/apps/link-chat/AppLinkChat.tsx index cad7f9282..6e5a97d7a 100644 --- a/src/apps/link-chat/AppLinkChat.tsx +++ b/src/apps/link-chat/AppLinkChat.tsx @@ -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'; diff --git a/src/apps/link-chat/LinkChatViewer.tsx b/src/apps/link-chat/LinkChatViewer.tsx index 84c9d757d..d36a65174 100644 --- a/src/apps/link-chat/LinkChatViewer.tsx +++ b/src/apps/link-chat/LinkChatViewer.tsx @@ -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)]} />, )} diff --git a/src/common/app.routes.ts b/src/common/app.routes.ts index 2b5d9e788..db4757d9e 100644 --- a/src/common/app.routes.ts +++ b/src/common/app.routes.ts @@ -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'; diff --git a/src/common/chats/ConversationHandler.ts b/src/common/chats/ConversationHandler.ts index 70b5ac6df..520fb815a 100644 --- a/src/common/chats/ConversationHandler.ts +++ b/src/common/chats/ConversationHandler.ts @@ -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); diff --git a/src/common/chats/ConversationsManager.ts b/src/common/chats/ConversationsManager.ts index ae858de1e..36f5b06ca 100644 --- a/src/common/chats/ConversationsManager.ts +++ b/src/common/chats/ConversationsManager.ts @@ -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. diff --git a/src/common/state/store-chats.ts b/src/common/state/store-chats.ts deleted file mode 100644 index 9119beeba..000000000 --- a/src/common/state/store-chats.ts +++ /dev/null @@ -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 = { - 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 | ((message: DMessage) => Partial), touchUpdated: boolean) => void; - updateMetadata: (conversationId: string, messageId: string, metadataDelta: Partial, 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 | ((conversation: DConversation) => Partial)) => void; -} - -type ConversationsStore = ChatState & ChatActions; - -export const useChatStore = create()(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 | ((conversation: DConversation) => Partial)) => - _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 | ((message: DMessage) => Partial), 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, 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); diff --git a/src/common/state/store-folders.ts b/src/common/state/store-folders.ts index fb318000b..19f262761 100644 --- a/src/common/state/store-folders.ts +++ b/src/common/state/store-folders.ts @@ -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 { diff --git a/src/common/stores/chat/chat.conversation.ts b/src/common/stores/chat/chat.conversation.ts new file mode 100644 index 000000000..46cb91143 --- /dev/null +++ b/src/common/stores/chat/chat.conversation.ts @@ -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}`; +} diff --git a/src/common/stores/chat/chat.message.ts b/src/common/stores/chat/chat.message.ts new file mode 100644 index 000000000..03a424a47 --- /dev/null +++ b/src/common/stores/chat/chat.message.ts @@ -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 } + | { type: 'function_response'; function: string; response: Record } + ; + + +// 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 = { + 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]; +} diff --git a/src/common/stores/chat/store-chats.ts b/src/common/stores/chat/store-chats.ts new file mode 100644 index 000000000..1b3103be6 --- /dev/null +++ b/src/common/stores/chat/store-chats.ts @@ -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 | ((message: DMessage) => Partial), touchUpdated: boolean) => void; + updateMetadata: (cId: DConversationId, mId: DMessageId, metadataDelta: Partial, 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 | ((conversation: DConversation) => Partial)) => void; +} + +type ConversationsStore = ChatState & ChatActions; + +const defaultConversations: DConversation[] = [createDConversation()]; + +export const useChatStore = create()(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 | ((conversation: DConversation) => Partial)) => + _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 | ((message: DMessage) => Partial), 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, 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, + }; +})); diff --git a/src/modules/aifn/autosuggestions/autoSuggestions.ts b/src/modules/aifn/autosuggestions/autoSuggestions.ts index be2336618..882b4e59f 100644 --- a/src/modules/aifn/autosuggestions/autoSuggestions.ts +++ b/src/modules/aifn/autosuggestions/autoSuggestions.ts @@ -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); } } diff --git a/src/modules/aifn/autotitle/autoTitle.ts b/src/modules/aifn/autotitle/autoTitle.ts index 69793bcba..74f8e2e4d 100644 --- a/src/modules/aifn/autotitle/autoTitle.ts +++ b/src/modules/aifn/autotitle/autoTitle.ts @@ -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}`; diff --git a/src/modules/aifn/digrams/DiagramsModal.tsx b/src/modules/aifn/digrams/DiagramsModal.tsx index 55e092f5f..c3f91ab95 100644 --- a/src/modules/aifn/digrams/DiagramsModal.tsx +++ b/src/modules/aifn/digrams/DiagramsModal.tsx @@ -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, diff --git a/src/modules/aifn/flatten/FlattenerModal.tsx b/src/modules/aifn/flatten/FlattenerModal.tsx index 088498689..648c2c8ee 100644 --- a/src/modules/aifn/flatten/FlattenerModal.tsx +++ b/src/modules/aifn/flatten/FlattenerModal.tsx @@ -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`; } diff --git a/src/modules/aifn/replyto/replyTo.ts b/src/modules/aifn/replyto/replyTo.ts index a28e4588b..089a78447 100644 --- a/src/modules/aifn/replyto/replyTo.ts +++ b/src/modules/aifn/replyto/replyTo.ts @@ -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: diff --git a/src/modules/beam/gather/beam.gather.ts b/src/modules/beam/gather/beam.gather.ts index 50e2ff8e8..60f55d210 100644 --- a/src/modules/beam/gather/beam.gather.ts +++ b/src/modules/beam/gather/beam.gather.ts @@ -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'; diff --git a/src/modules/beam/gather/instructions/ChatGenerateInstruction.tsx b/src/modules/beam/gather/instructions/ChatGenerateInstruction.tsx index 1f8452729..f10bb74b3 100644 --- a/src/modules/beam/gather/instructions/ChatGenerateInstruction.tsx +++ b/src/modules/beam/gather/instructions/ChatGenerateInstruction.tsx @@ -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); @@ -66,8 +66,8 @@ export async function executeChatGenerate(_i: ChatGenerateInstruction, inputs: E const onMessageUpdate = (update: Partial) => { // 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( - {update.text?.length || 0} characters, + {singleTextOrThrow(update as any)?.length || 0} characters, ); 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); }); } diff --git a/src/modules/beam/gather/instructions/beam.gather.execution.tsx b/src/modules/beam/gather/instructions/beam.gather.execution.tsx index 1836f9cf0..2ce5defdf 100644 --- a/src/modules/beam/gather/instructions/beam.gather.execution.tsx +++ b/src/modules/beam/gather/instructions/beam.gather.execution.tsx @@ -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'; diff --git a/src/modules/beam/scatter/BeamScatterInput.tsx b/src/modules/beam/scatter/BeamScatterInput.tsx index bf851e9c9..b395f6891 100644 --- a/src/modules/beam/scatter/BeamScatterInput.tsx +++ b/src/modules/beam/scatter/BeamScatterInput.tsx @@ -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'; diff --git a/src/modules/beam/scatter/beam.scatter.ts b/src/modules/beam/scatter/beam.scatter.ts index d229a6d30..c3b57f871 100644 --- a/src/modules/beam/scatter/beam.scatter.ts +++ b/src/modules/beam/scatter/beam.scatter.ts @@ -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> { - 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>; // Cached conversions as BlobData objects } +export type DBlobId = string; + export function createDBlobBase>(type: TType, label: string, data: DBlobData, origin: ItemDataOrigin, metadata: TMeta): DBlobBase { return { id: uuidv4(), diff --git a/src/modules/trade/ExportChats.tsx b/src/modules/trade/ExportChats.tsx index 0388fc3b8..d3ac28afc 100644 --- a/src/modules/trade/ExportChats.tsx +++ b/src/modules/trade/ExportChats.tsx @@ -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'; diff --git a/src/modules/trade/ImportChats.tsx b/src/modules/trade/ImportChats.tsx index 444747760..a7bd54e7d 100644 --- a/src/modules/trade/ImportChats.tsx +++ b/src/modules/trade/ImportChats.tsx @@ -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'; diff --git a/src/modules/trade/ImportOutcomeModal.tsx b/src/modules/trade/ImportOutcomeModal.tsx index 5145629c8..a3fe035ae 100644 --- a/src/modules/trade/ImportOutcomeModal.tsx +++ b/src/modules/trade/ImportOutcomeModal.tsx @@ -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'; diff --git a/src/modules/trade/TradeModal.tsx b/src/modules/trade/TradeModal.tsx index 3b35ad55b..b69f91df7 100644 --- a/src/modules/trade/TradeModal.tsx +++ b/src/modules/trade/TradeModal.tsx @@ -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'; diff --git a/src/modules/trade/link/ChatLinkExport.tsx b/src/modules/trade/link/ChatLinkExport.tsx index db9cf9342..7bf386ca3 100644 --- a/src/modules/trade/link/ChatLinkExport.tsx +++ b/src/modules/trade/link/ChatLinkExport.tsx @@ -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'; diff --git a/src/modules/trade/publish/PublishExport.tsx b/src/modules/trade/publish/PublishExport.tsx index f5cd59826..09ec5c397 100644 --- a/src/modules/trade/publish/PublishExport.tsx +++ b/src/modules/trade/publish/PublishExport.tsx @@ -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'; diff --git a/src/modules/trade/trade.client.ts b/src/modules/trade/trade.client.ts index d1084ef14..cd4b31f9d 100644 --- a/src/modules/trade/trade.client.ts +++ b/src/modules/trade/trade.client.ts @@ -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';