🔍 refactor: Search & Message Retrieval (#6903)

* refactor: conversation search fetch

* refactor: Message and Convo fetch with paramters and search

* refactor: update search states and cleanup old store states

* refactor: re-enable search API; fix: search conversation

* fix: message's convo fetch

* fix: redirect when searching

* chore: use logger instead of console

* fix: search message loading

* feat: small optimizations

* feat(Message): remove cache for search path

* fix: handle delete of all archivedConversation and sharedLinks

* chore: cleanup

* fix: search messages

* style: update ConvoOptions styles

* refactor(SearchButtons): streamline conversation fetching and remove unused state

* fix: ensure messages are invalidated after fetching conversation data

* fix: add iconURL to conversation query selection

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-04-17 03:07:43 +02:00 committed by GitHub
parent 851938e7a6
commit 88f4ad7c47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 489 additions and 576 deletions

View file

@ -1,59 +0,0 @@
const mergeSort = require('./mergeSort');
const { cleanUpPrimaryKeyValue } = require('./misc');
function reduceMessages(hits) {
const counts = {};
for (const hit of hits) {
if (!counts[hit.conversationId]) {
counts[hit.conversationId] = 1;
} else {
counts[hit.conversationId]++;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count,
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
function reduceHits(hits, titles = []) {
const counts = {};
const titleMap = {};
const convos = [...hits, ...titles];
for (const convo of convos) {
const currentId = cleanUpPrimaryKeyValue(convo.conversationId);
if (!counts[currentId]) {
counts[currentId] = 1;
} else {
counts[currentId]++;
}
if (convo.title) {
// titleMap[currentId] = convo._formatted.title;
titleMap[currentId] = convo.title;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count,
title: titleMap[conversationId] ? titleMap[conversationId] : null,
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
module.exports = { reduceMessages, reduceHits };

View file

@ -193,7 +193,7 @@ module.exports = {
try {
const convos = await Conversation.find(query)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec',
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
)
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
.limit(limit + 1)

View file

@ -88,8 +88,8 @@ const startServer = async () => {
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/ask', routes.ask);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);

View file

@ -10,6 +10,7 @@ const balance = require('./balance');
const plugins = require('./plugins');
const bedrock = require('./bedrock');
const actions = require('./actions');
const banner = require('./banner');
const search = require('./search');
const models = require('./models');
const convos = require('./convos');
@ -25,7 +26,6 @@ const edit = require('./edit');
const keys = require('./keys');
const user = require('./user');
const ask = require('./ask');
const banner = require('./banner');
module.exports = {
ask,
@ -38,13 +38,14 @@ module.exports = {
oauth,
files,
share,
banner,
agents,
bedrock,
convos,
search,
prompts,
config,
models,
bedrock,
prompts,
plugins,
actions,
presets,
@ -55,5 +56,4 @@ module.exports = {
assistants,
categories,
staticRoute,
banner,
};

View file

@ -10,12 +10,90 @@ const {
} = require('~/models');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { getConvosQueried } = require('~/models/Conversation');
const { countTokens } = require('~/server/utils');
const { Message } = require('~/models/Message');
const { logger } = require('~/config');
const router = express.Router();
router.use(requireJwtAuth);
router.get('/', async (req, res) => {
try {
const user = req.user.id ?? '';
const {
cursor = null,
sortBy = 'createdAt',
sortDirection = 'desc',
pageSize: pageSizeRaw,
conversationId,
messageId,
search,
} = req.query;
const pageSize = parseInt(pageSizeRaw, 10) || 25;
let response;
const sortField = ['endpoint', 'createdAt', 'updatedAt'].includes(sortBy)
? sortBy
: 'createdAt';
const sortOrder = sortDirection === 'asc' ? 1 : -1;
if (conversationId && messageId) {
const message = await Message.findOne({ conversationId, messageId, user: user }).lean();
response = { messages: message ? [message] : [], nextCursor: null };
} else if (conversationId) {
const filter = { conversationId, user: user };
if (cursor) {
filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor };
}
const messages = await Message.find(filter)
.sort({ [sortField]: sortOrder })
.limit(pageSize + 1)
.lean();
const nextCursor = messages.length > pageSize ? messages.pop()[sortField] : null;
response = { messages, nextCursor };
} else if (search) {
const searchResults = await Message.meiliSearch(search, undefined, true);
const messages = searchResults.hits || [];
const result = await getConvosQueried(req.user.id, messages, cursor);
const activeMessages = [];
for (let i = 0; i < messages.length; i++) {
let message = messages[i];
if (message.conversationId.includes('--')) {
message.conversationId = cleanUpPrimaryKeyValue(message.conversationId);
}
if (result.convoMap[message.conversationId]) {
const convo = result.convoMap[message.conversationId];
const dbMessage = await getMessage({ user, messageId: message.messageId });
activeMessages.push({
...message,
title: convo.title,
conversationId: message.conversationId,
model: convo.model,
isCreatedByUser: dbMessage?.isCreatedByUser,
endpoint: dbMessage?.endpoint,
iconURL: dbMessage?.iconURL,
});
}
}
response = { messages: activeMessages, nextCursor: null };
} else {
response = { messages: [], nextCursor: null };
}
res.status(200).json(response);
} catch (error) {
logger.error('Error fetching messages:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/artifact/:messageId', async (req, res) => {
try {
const { messageId } = req.params;

View file

@ -1,14 +1,11 @@
const { Keyv } = require('keyv');
const express = require('express');
const { MeiliSearch } = require('meilisearch');
const { Conversation, getConvosQueried } = require('~/models/Conversation');
const { Conversation } = require('~/models/Conversation');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { Message, getMessage } = require('~/models/Message');
const { reduceHits } = require('~/lib/utils/reduceHits');
const { Message } = require('~/models/Message');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const router = express.Router();
@ -25,88 +22,6 @@ router.get('/sync', async function (req, res) {
res.send('synced');
});
router.get('/', async function (req, res) {
try {
const user = req.user.id ?? '';
const { q, cursor = 'start' } = req.query;
const key = `${user}:search:${q}:${cursor}`;
const cached = await cache.get(key);
if (cached) {
logger.debug('[/search] cache hit: ' + key);
return res.status(200).send(cached);
}
const [messageResults, titleResults] = await Promise.all([
Message.meiliSearch(q, undefined, true),
Conversation.meiliSearch(q),
]);
const messages = messageResults.hits;
const titles = titleResults.hits;
const sortedHits = reduceHits(messages, titles);
const result = await getConvosQueried(user, sortedHits, cursor);
const activeMessages = [];
for (let i = 0; i < messages.length; i++) {
let message = messages[i];
if (message.conversationId.includes('--')) {
message.conversationId = cleanUpPrimaryKeyValue(message.conversationId);
}
if (result.convoMap[message.conversationId]) {
const convo = result.convoMap[message.conversationId];
const dbMessage = await getMessage({ user, messageId: message.messageId });
activeMessages.push({
...message,
title: convo.title,
conversationId: message.conversationId,
model: convo.model,
isCreatedByUser: dbMessage?.isCreatedByUser,
endpoint: dbMessage?.endpoint,
iconURL: dbMessage?.iconURL,
});
}
}
const activeConversations = [];
for (const convId in result.convoMap) {
const convo = result.convoMap[convId];
if (convo.isArchived) {
continue;
}
activeConversations.push({
title: convo.title,
user: convo.user,
conversationId: convo.conversationId,
endpoint: convo.endpoint,
endpointType: convo.endpointType,
model: convo.model,
createdAt: convo.createdAt,
updatedAt: convo.updatedAt,
});
}
if (result.cache) {
result.cache.messages = activeMessages;
result.cache.conversations = activeConversations;
cache.set(key, result.cache, expiration);
}
const response = {
nextCursor: result.nextCursor ?? null,
messages: activeMessages,
conversations: activeConversations,
};
res.status(200).send(response);
} catch (error) {
logger.error('[/search] Error while searching messages & conversations', error);
res.status(500).send({ message: 'Error searching' });
}
});
router.get('/test', async function (req, res) {
const { q } = req.query;
const messages = (

View file

@ -1,5 +0,0 @@
import { createContext, useContext } from 'react';
import { UseSearchMessagesResult } from '~/hooks/Conversations/useSearch';
export const SearchContext = createContext<UseSearchMessagesResult>({} as UseSearchMessagesResult);
export const useSearchContext = () => useContext(SearchContext);

View file

@ -4,7 +4,6 @@ export { default as AgentsProvider } from './AgentsContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';
export * from './AddedChatContext';
export * from './EditorContext';

View file

@ -43,7 +43,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const isSearching = useRecoilValue(store.isSearching);
const search = useRecoilValue(store.search);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const chatDirection = useRecoilValue(store.chatDirection);
@ -151,10 +151,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
if (!search.isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
}
}, [isSearching, disableInputs]);
}, [search.isSearching, disableInputs]);
useEffect(() => {
if (textAreaRef.current) {

View file

@ -1,34 +1,63 @@
import { Link } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type { ConversationCursorData } from '~/utils';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { findConversationInInfinite } from '~/utils';
import { useSearchContext } from '~/Providers';
import store from '~/store';
export default function SearchButtons({ message }: { message: TMessage }) {
const localize = useLocalize();
const { searchQueryRes } = useSearchContext();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const { navigateWithLastTools } = useNavigateToConvo();
const conversationId = message.conversationId ?? '';
const clickHandler = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!conversationId) {
return;
}
let title = message.title ?? '';
let cachedConvo = queryClient.getQueryData<TConversation>([
QueryKeys.conversation,
conversationId,
]);
const convos = queryClient.getQueryData<InfiniteData<ConversationCursorData>>([
QueryKeys.allConversations,
{ search: search.debouncedQuery },
]);
if (!cachedConvo && convos) {
cachedConvo = findConversationInInfinite(convos, conversationId);
}
if (!title) {
title = cachedConvo?.title ?? '';
}
document.title = title;
navigateWithLastTools(
cachedConvo ??
({
conversationId,
title,
} as TConversation),
true,
true,
);
};
if (!conversationId) {
return null;
}
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const conversation = findConversationInInfinite(searchQueryRes?.data, conversationId);
if (!conversation) {
return;
}
document.title = message.title ?? '';
navigateWithLastTools(conversation, true, true);
};
return (
<div className="visible mt-0 flex items-center justify-center gap-1 self-end text-text-secondary lg:justify-start">
<button
type="button"
className="ml-0 flex cursor-pointer items-center gap-1.5 rounded-md p-1 text-xs hover:text-text-primary hover:underline"
onClick={clickHandler}
title={localize('com_ui_go_to_conversation')}

View file

@ -14,13 +14,20 @@ interface ConversationsProps {
toggleNav: () => void;
containerRef: React.RefObject<HTMLDivElement | List>;
loadMoreConversations: () => void;
isFetchingNextPage: boolean;
isLoading: boolean;
isSearchLoading: boolean;
}
const LoadingSpinner = memo(() => (
<Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-text-primary" />
));
const LoadingSpinner = memo(() => {
const localize = useLocalize();
return (
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
<Spinner className="h-4 w-4 text-text-primary" />
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
</div>
);
});
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize();
@ -35,7 +42,8 @@ DateLabel.displayName = 'DateLabel';
type FlattenedItem =
| { type: 'header'; groupName: string }
| { type: 'convo'; convo: TConversation };
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
const MemoizedConvo = memo(
({
@ -74,7 +82,7 @@ const Conversations: FC<ConversationsProps> = ({
toggleNav,
containerRef,
loadMoreConversations,
isFetchingNextPage,
isLoading,
isSearchLoading,
}) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
@ -103,8 +111,12 @@ const Conversations: FC<ConversationsProps> = ({
items.push({ type: 'header', groupName });
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
});
if (isLoading) {
items.push({ type: 'loading' } as any);
}
return items;
}, [groupedConversations]);
}, [groupedConversations, isLoading]);
const cache = useMemo(
() =>
@ -113,7 +125,16 @@ const Conversations: FC<ConversationsProps> = ({
defaultHeight: convoHeight,
keyMapper: (index) => {
const item = flattenedItems[index];
return item.type === 'header' ? `header-${index}` : `convo-${item.convo.conversationId}`;
if (item.type === 'header') {
return `header-${index}`;
}
if (item.type === 'convo') {
return `convo-${item.convo.conversationId}`;
}
if (item.type === 'loading') {
return `loading-${index}`;
}
return `unknown-${index}`;
},
}),
[flattenedItems, convoHeight],
@ -122,20 +143,31 @@ const Conversations: FC<ConversationsProps> = ({
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];
if (item.type === 'loading') {
return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
<LoadingSpinner />
</div>
)}
</CellMeasurer>
);
}
return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
{item.type === 'header' ? (
<DateLabel groupName={item.groupName} />
) : (
) : item.type === 'convo' ? (
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
/>
)}
) : null}
</div>
)}
</CellMeasurer>
@ -156,7 +188,7 @@ const Conversations: FC<ConversationsProps> = ({
const handleRowsRendered = useCallback(
({ stopIndex }: { stopIndex: number }) => {
if (stopIndex >= flattenedItems.length - 2) {
if (stopIndex >= flattenedItems.length - 8) {
throttledLoadMore();
}
},
@ -193,11 +225,6 @@ const Conversations: FC<ConversationsProps> = ({
</AutoSizer>
</div>
)}
{isFetchingNextPage && !isSearchLoading && (
<div className="mt-2">
<LoadingSpinner />
</div>
)}
</div>
);
};

View file

@ -74,6 +74,7 @@ export function DeleteConversationDialog({
<OGDialogContent
title={localize('com_ui_delete_confirm') + ' ' + title}
className="w-11/12 max-w-md"
showCloseButton={false}
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>

View file

@ -1,11 +1,7 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type {
TConversation,
ConversationListResponse,
SearchConversationListResponse,
} from 'librechat-data-provider';
import type { TConversation, ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import {
useLocalize,
@ -17,8 +13,6 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components';
import NavToggle from './NavToggle';
import SearchBar from './SearchBar';
import NewChat from './NewChat';
@ -74,71 +68,48 @@ const Nav = memo(
permission: Permissions.USE,
});
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const isSearchTyping = useRecoilValue(store.isSearchTyping);
const { searchQuery, searchQueryRes } = useSearchContext();
const search = useRecoilValue(store.search);
const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery(
{
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching, refetch } =
useConversationsInfiniteQuery(
{
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const computedHasNextPage = useMemo(() => {
if (searchQuery && searchQueryRes?.data) {
const pages = searchQueryRes.data.pages;
return pages[pages.length - 1]?.nextCursor !== null;
} else if (data?.pages && data.pages.length > 0) {
if (data?.pages && data.pages.length > 0) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [searchQuery, searchQueryRes?.data, data?.pages]);
}, [data?.pages]);
const outerContainerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<any>(null);
const { moveToTop } = useNavScrolling<
ConversationListResponse | SearchConversationListResponse
>({
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
fetchNextPage: async (options?) => {
if (computedHasNextPage) {
if (searchQuery && searchQueryRes) {
const pages = searchQueryRes.data?.pages;
if (pages && pages.length > 0 && pages[pages.length - 1]?.nextCursor !== null) {
return searchQueryRes.fetchNextPage(options);
}
} else {
return fetchNextPage(options);
}
return fetchNextPage(options);
}
return Promise.resolve(
{} as InfiniteQueryObserverResult<
SearchConversationListResponse | ConversationListResponse,
unknown
>,
{} as InfiniteQueryObserverResult<ConversationListResponse, unknown>,
);
},
isFetchingNext: searchQuery
? (searchQueryRes?.isFetchingNextPage ?? false)
: isFetchingNextPage,
isFetchingNext: isFetchingNextPage,
});
const conversations = useMemo(() => {
if (searchQuery && searchQueryRes?.data) {
return searchQueryRes.data.pages.flatMap(
(page) => page.conversations ?? [],
) as TConversation[];
}
return data ? data.pages.flatMap((page) => page.conversations) : [];
}, [data, searchQuery, searchQueryRes?.data]);
}, [data]);
const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
@ -183,7 +154,7 @@ const Nav = memo(
const subHeaders = useMemo(
() => (
<>
{isSearchEnabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
@ -194,14 +165,22 @@ const Nav = memo(
)}
</>
),
[isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
[search.enabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
);
const isSearchLoading =
!!searchQuery &&
(isSearchTyping ||
(searchQueryRes?.isLoading ?? false) ||
(searchQueryRes?.isFetching ?? false));
const [isSearchLoading, setIsSearchLoading] = useState(
!!search.query && (search.isTyping || isLoading || isFetching),
);
useEffect(() => {
if (search.isTyping) {
setIsSearchLoading(true);
} else if (!isLoading && !isFetching) {
setIsSearchLoading(false);
} else if (!!search.query && (isLoading || isFetching)) {
setIsSearchLoading(true);
}
}, [search.query, search.isTyping, isLoading, isFetching]);
return (
<>
@ -243,7 +222,7 @@ const Nav = memo(
toggleNav={itemToggleNav}
containerRef={listRef}
loadMoreConversations={loadMoreConversations}
isFetchingNextPage={isFetchingNextPage || showLoading}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
/>
</div>

View file

@ -15,8 +15,9 @@ import { cn } from '~/utils';
import store from '~/store';
const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConversation | null }) => {
const searchQuery = useRecoilValue(store.searchQuery);
const { data: endpointsConfig } = useGetEndpointsQuery();
const search = useRecoilValue(store.search);
const searchQuery = search.debouncedQuery;
const computedIcon = useMemo(() => {
if (searchQuery) {

View file

@ -1,10 +1,10 @@
import { forwardRef, useState, useCallback, useMemo, useEffect, Ref } from 'react';
import debounce from 'lodash/debounce';
import { Search, X } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useState, useCallback, useMemo, Ref } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocalize, useNewConvo } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -17,29 +17,34 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const localize = useLocalize();
const location = useLocation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { isSmallScreen } = props;
const [text, setText] = useState('');
const [showClearIcon, setShowClearIcon] = useState(false);
const { newConversation } = useNewConvo();
const clearConvoState = store.useClearConvoState();
const setSearchQuery = useSetRecoilState(store.searchQuery);
const setIsSearching = useSetRecoilState(store.isSearching);
const setIsSearchTyping = useSetRecoilState(store.isSearchTyping);
const setSearchState = useSetRecoilState(store.search);
const search = useRecoilValue(store.search);
const clearSearch = useCallback(() => {
if (location.pathname.includes('/search')) {
newConversation({ disableFocus: true });
navigate('/c/new', { replace: true });
}
}, [newConversation, location.pathname]);
}, [newConversation, location.pathname, navigate]);
const clearText = useCallback(() => {
setShowClearIcon(false);
setSearchQuery('');
clearSearch();
setText('');
}, [setSearchQuery, clearSearch]);
setSearchState((prev) => ({
...prev,
query: '',
debouncedQuery: '',
isTyping: false,
}));
clearSearch();
}, [setSearchState, clearSearch]);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
const { value } = e.target as HTMLInputElement;
@ -50,34 +55,48 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const sendRequest = useCallback(
(value: string) => {
setSearchQuery(value);
if (!value) {
return;
}
queryClient.invalidateQueries([QueryKeys.messages]);
clearConvoState();
},
[queryClient, clearConvoState, setSearchQuery],
[queryClient],
);
const debouncedSendRequest = useMemo(
const debouncedSetDebouncedQuery = useMemo(
() =>
debounce((value: string) => {
setSearchState((prev) => ({ ...prev, debouncedQuery: value, isTyping: false }));
sendRequest(value);
}, 350),
[sendRequest, setIsSearchTyping],
}, 500),
[setSearchState, sendRequest],
);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setShowClearIcon(value.length > 0);
setText(value);
setSearchQuery(value);
setIsSearchTyping(true);
// debounce only the API call
debouncedSendRequest(value);
setSearchState((prev) => ({
...prev,
query: value,
isTyping: true,
}));
debouncedSetDebouncedQuery(value);
if (value.length > 0 && location.pathname !== '/search') {
navigate('/search', { replace: true });
} else if (value.length === 0 && location.pathname === '/search') {
navigate('/c/new', { replace: true });
}
};
// Automatically set isTyping to false when loading is done and debouncedQuery matches query
// (prevents stuck loading state if input is still focused)
useEffect(() => {
if (search.isTyping && !search.isSearching && search.debouncedQuery === search.query) {
setSearchState((prev) => ({ ...prev, isTyping: false }));
}
}, [search.isTyping, search.isSearching, search.debouncedQuery, search.query, setSearchState]);
return (
<div
ref={ref}
@ -98,8 +117,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
aria-label={localize('com_nav_search_placeholder')}
placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp}
onFocus={() => setIsSearching(true)}
onBlur={() => setIsSearching(true)}
onFocus={() => setSearchState((prev) => ({ ...prev, isSearching: true }))}
onBlur={() => setSearchState((prev) => ({ ...prev, isSearching: false }))}
autoComplete="off"
dir="auto"
/>

View file

@ -272,7 +272,6 @@ export default function ArchivedChatsTable({
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
manualSorting={true} // Ensures server-side sorting
/>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>

View file

@ -216,8 +216,8 @@ export default function DataTable<TData, TValue>({
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const search = useRecoilValue(store.search);
const [isDeleting, setIsDeleting] = useState(false);
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -331,7 +331,6 @@ export default function DataTable<TData, TValue>({
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
await onDelete(itemsToDelete);
setRowSelection({});
// await fetchNextPage?.({ pageParam: lastPage?.nextCursor });
} finally {
setIsDeleting(false);
}
@ -375,7 +374,7 @@ export default function DataTable<TData, TValue>({
localize={localize}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && isSearchEnabled && (
{filterColumn !== undefined && table.getColumn(filterColumn) && search.enabled && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}

View file

@ -16,11 +16,10 @@ import type t from 'librechat-data-provider';
import type {
Action,
TPreset,
TPlugin,
ConversationListResponse,
ConversationListParams,
SearchConversationListResponse,
SearchConversationListParams,
MessagesListParams,
MessagesListResponse,
Assistant,
AssistantListParams,
AssistantListResponse,
@ -30,6 +29,7 @@ import type {
SharedLinksListParams,
SharedLinksResponse,
} from 'librechat-data-provider';
import type { ConversationCursorData } from '~/utils/convos';
export const useGetPresetsQuery = (
config?: UseQueryOptions<TPreset[]>,
@ -68,9 +68,9 @@ export const useGetConvoIdQuery = (
[QueryKeys.conversation, id],
() => {
// Try to find in all fetched infinite pages
const convosQuery = queryClient.getQueryData<
InfiniteData<import('~/utils').ConversationCursorData>
>([QueryKeys.allConversations]);
const convosQuery = queryClient.getQueryData<InfiniteData<ConversationCursorData>>([
QueryKeys.allConversations,
]);
const found = convosQuery?.pages
.flatMap((page) => page.conversations)
.find((c) => c.conversationId === id);
@ -90,30 +90,6 @@ export const useGetConvoIdQuery = (
);
};
export const useSearchInfiniteQuery = (
params?: SearchConversationListParams,
config?: UseInfiniteQueryOptions<SearchConversationListResponse, unknown>,
) => {
return useInfiniteQuery<SearchConversationListResponse, unknown>(
[QueryKeys.searchConversations, params],
({ pageParam = null }) =>
dataService
.listConversations({
...params,
search: params?.search ?? '',
cursor: pageParam?.toString(),
})
.then((res) => ({ ...res })) as Promise<SearchConversationListResponse>,
{
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};
export const useConversationsInfiniteQuery = (
params: ConversationListParams,
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
@ -134,7 +110,36 @@ export const useConversationsInfiniteQuery = (
search,
cursor: pageParam?.toString(),
}),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
...config,
});
};
export const useMessagesInfiniteQuery = (
params: MessagesListParams,
config?: UseInfiniteQueryOptions<MessagesListResponse, unknown>,
) => {
const { sortBy, sortDirection, pageSize, conversationId, messageId, search } = params;
return useInfiniteQuery<MessagesListResponse>({
queryKey: [
QueryKeys.messages,
{ sortBy, sortDirection, pageSize, conversationId, messageId, search },
],
queryFn: ({ pageParam }) =>
dataService.listMessages({
sortBy,
sortDirection,
pageSize,
conversationId,
messageId,
search,
cursor: pageParam?.toString(),
}),
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
@ -159,7 +164,7 @@ export const useSharedLinksQuery = (
sortBy,
sortDirection,
}),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes

View file

@ -1,7 +1,7 @@
export { default as useSearch } from './useSearch';
export { default as usePresets } from './usePresets';
export { default as useGetSender } from './useGetSender';
export { default as useDefaultConvo } from './useDefaultConvo';
export { default as useSearchEnabled } from './useSearchEnabled';
export { default as useGenerateConvo } from './useGenerateConvo';
export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useBookmarkSuccess } from './useBookmarkSuccess';

View file

@ -29,6 +29,7 @@ const useNavigateToConvo = (index = 0) => {
dataService.getConversationById(conversationId),
);
logger.log('conversation', 'Fetched fresh conversation data', data);
await queryClient.invalidateQueries([QueryKeys.messages, conversationId]);
setConversation(data);
} catch (error) {
console.error('Error fetching conversation data on navigation', error);
@ -38,6 +39,7 @@ const useNavigateToConvo = (index = 0) => {
const navigateToConvo = (
conversation?: TConversation | null,
_resetLatestMessage = true,
/** Likely need to remove this since it happens after fetching conversation data */
invalidateMessages = false,
) => {
if (!conversation) {

View file

@ -1,102 +0,0 @@
import { useEffect, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useNavigate, useLocation } from 'react-router-dom';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { SearchConversationListResponse } from 'librechat-data-provider';
import { useSearchInfiniteQuery, useGetSearchEnabledQuery } from '~/data-provider';
import useNewConvo from '~/hooks/useNewConvo';
import store from '~/store';
export interface UseSearchMessagesResult {
searchQuery: string;
searchQueryRes: UseInfiniteQueryResult<SearchConversationListResponse, unknown> | undefined;
}
export default function useSearchMessages({
isAuthenticated,
}: {
isAuthenticated: boolean;
}): UseSearchMessagesResult {
const navigate = useNavigate();
const location = useLocation();
const { switchToConversation } = useNewConvo();
const searchPlaceholderConversation = useCallback(() => {
switchToConversation({
conversationId: 'search',
title: 'Search',
endpoint: null,
createdAt: '',
updatedAt: '',
});
}, [switchToConversation]);
const searchQuery = useRecoilValue(store.searchQuery);
const setIsSearchEnabled = useSetRecoilState(store.isSearchEnabled);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 350); // 350ms debounce
return () => clearTimeout(handler);
}, [searchQuery]);
const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated });
const searchQueryRes = useSearchInfiniteQuery(
{ nextCursor: null, search: debouncedSearchQuery, pageSize: 20 },
{ enabled: isAuthenticated && !!debouncedSearchQuery },
) as UseInfiniteQueryResult<SearchConversationListResponse, unknown> | undefined;
useEffect(() => {
if (searchQuery && searchQuery.length > 0) {
navigate('/search', { replace: true });
return;
}
if (location.pathname && location.pathname.includes('/c/')) {
return;
}
navigate('/c/new', { replace: true });
/* Disabled eslint rule because we don't want to run this effect when location changes */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate, searchQuery]);
useEffect(() => {
if (searchEnabledQuery.data === true) {
setIsSearchEnabled(searchEnabledQuery.data);
} else if (searchEnabledQuery.isError) {
console.error('Failed to get search enabled', searchEnabledQuery.error);
}
}, [
searchEnabledQuery.data,
searchEnabledQuery.error,
searchEnabledQuery.isError,
setIsSearchEnabled,
]);
const onSearchSuccess = useCallback(
() => searchPlaceholderConversation(),
[searchPlaceholderConversation],
);
useEffect(() => {
// we use isInitialLoading here instead of isLoading because query is disabled by default
if (searchQueryRes?.data) {
onSearchSuccess();
}
}, [searchQueryRes?.data, searchQueryRes?.isInitialLoading, onSearchSuccess]);
const setIsSearchTyping = useSetRecoilState(store.isSearchTyping);
useEffect(() => {
if (!searchQueryRes?.isLoading && !searchQueryRes?.isFetching) {
setIsSearchTyping(false);
}
}, [searchQueryRes?.isLoading, searchQueryRes?.isFetching, setIsSearchTyping]);
return {
searchQuery,
searchQueryRes,
};
}

View file

@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { useGetSearchEnabledQuery } from '~/data-provider';
import { logger } from '~/utils';
import store from '~/store';
export default function useSearchEnabled(isAuthenticated: boolean) {
const setSearch = useSetRecoilState(store.search);
const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated });
useEffect(() => {
if (searchEnabledQuery.data === true) {
setSearch((prev) => ({ ...prev, enabled: searchEnabledQuery.data }));
} else if (searchEnabledQuery.isError) {
logger.error('Failed to get search enabled: ', searchEnabledQuery.error);
}
}, [searchEnabledQuery.data, searchEnabledQuery.error, searchEnabledQuery.isError, setSearch]);
return searchEnabledQuery;
}

View file

@ -1,14 +1,19 @@
import React, { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import type { ContextType } from '~/common';
import {
useAuthContext,
useAssistantsMap,
useAgentsMap,
useFileMap,
useSearchEnabled,
} from '~/hooks';
import {
AgentsMapContext,
AssistantsMapContext,
FileMapContext,
SearchContext,
SetConvoProvider,
} from '~/Providers';
import { useAuthContext, useAssistantsMap, useAgentsMap, useFileMap, useSearch } from '~/hooks';
import TermsAndConditionsModal from '~/components/ui/TermsAndConditionsModal';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import { Nav, MobileNav } from '~/components/Nav';
@ -26,13 +31,14 @@ export default function Root() {
const assistantsMap = useAssistantsMap({ isAuthenticated });
const agentsMap = useAgentsMap({ isAuthenticated });
const fileMap = useFileMap({ isAuthenticated });
const search = useSearch({ isAuthenticated });
const { data: config } = useGetStartupConfig();
const { data: termsData } = useUserTermsQuery({
enabled: isAuthenticated && config?.interface?.termsOfService?.modalAcceptance === true,
});
useSearchEnabled(isAuthenticated);
useEffect(() => {
if (termsData) {
setShowTerms(!termsData.termsAccepted);
@ -43,7 +49,6 @@ export default function Root() {
setShowTerms(false);
};
// Pass the desired redirect parameter to logout
const handleDeclineTerms = () => {
setShowTerms(false);
logout('/login?redirect=false');
@ -55,34 +60,32 @@ export default function Root() {
return (
<SetConvoProvider>
<SearchContext.Provider value={search}>
<FileMapContext.Provider value={fileMap}>
<AssistantsMapContext.Provider value={assistantsMap}>
<AgentsMapContext.Provider value={agentsMap}>
<Banner onHeightChange={setBannerHeight} />
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<MobileNav setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>
<FileMapContext.Provider value={fileMap}>
<AssistantsMapContext.Provider value={assistantsMap}>
<AgentsMapContext.Provider value={agentsMap}>
<Banner onHeightChange={setBannerHeight} />
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<MobileNav setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>
</div>
</AgentsMapContext.Provider>
{config?.interface?.termsOfService?.modalAcceptance === true && (
<TermsAndConditionsModal
open={showTerms}
onOpenChange={setShowTerms}
onAccept={handleAcceptTerms}
onDecline={handleDeclineTerms}
title={config.interface.termsOfService.modalTitle}
modalContent={config.interface.termsOfService.modalContent}
/>
)}
</AssistantsMapContext.Provider>
</FileMapContext.Provider>
</SearchContext.Provider>
</div>
</AgentsMapContext.Provider>
{config?.interface?.termsOfService?.modalAcceptance === true && (
<TermsAndConditionsModal
open={showTerms}
onOpenChange={setShowTerms}
onAccept={handleAcceptTerms}
onDecline={handleDeclineTerms}
title={config.interface.termsOfService.modalTitle}
modalContent={config.interface.termsOfService.modalContent}
/>
)}
</AssistantsMapContext.Provider>
</FileMapContext.Provider>
</SetConvoProvider>
);
}

View file

@ -1,47 +1,62 @@
import { useEffect, useMemo } from 'react';
import type { FetchNextPageOptions } from '@tanstack/react-query';
import { useToastContext, useSearchContext, useFileMapContext } from '~/Providers';
import { useRecoilValue } from 'recoil';
import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages';
import { useNavScrolling, useLocalize, useAuthContext } from '~/hooks';
import SearchMessage from '~/components/Chat/Messages/SearchMessage';
import { useNavScrolling, useLocalize } from '~/hooks';
import { useToastContext, useFileMapContext } from '~/Providers';
import { useMessagesInfiniteQuery } from '~/data-provider';
import { Spinner } from '~/components';
import { buildTree } from '~/utils';
import { useRecoilValue } from 'recoil';
import store from '~/store';
export default function Search() {
const localize = useLocalize();
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const { searchQuery, searchQueryRes } = useSearchContext();
const isSearchTyping = useRecoilValue(store.isSearchTyping);
const { isAuthenticated } = useAuthContext();
const search = useRecoilValue(store.search);
const searchQuery = search.debouncedQuery;
const {
data: searchMessages,
isLoading,
isError,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useMessagesInfiniteQuery(
{
search: searchQuery || undefined,
},
{
enabled: isAuthenticated && !!searchQuery,
staleTime: 30000,
cacheTime: 300000,
},
);
const { containerRef } = useNavScrolling({
nextCursor: searchQueryRes?.data?.pages[searchQueryRes.data.pages.length - 1]?.nextCursor,
nextCursor: searchMessages?.pages[searchMessages.pages.length - 1]?.nextCursor,
setShowLoading: () => ({}),
fetchNextPage: searchQueryRes?.fetchNextPage
? (options?: FetchNextPageOptions) => searchQueryRes.fetchNextPage(options)
: undefined,
isFetchingNext: searchQueryRes?.isFetchingNextPage ?? false,
fetchNextPage: fetchNextPage,
isFetchingNext: isFetchingNextPage,
});
useEffect(() => {
if (searchQueryRes?.error) {
showToast({ message: 'An error occurred during search', status: 'error' });
}
}, [searchQueryRes?.error, showToast]);
const messages = useMemo(() => {
const msgs = searchQueryRes?.data?.pages.flatMap((page) => page.messages) || [];
const msgs = searchMessages?.pages.flatMap((page) => page.messages) || [];
const dataTree = buildTree({ messages: msgs, fileMap });
return dataTree?.length === 0 ? null : (dataTree ?? null);
}, [fileMap, searchQueryRes?.data?.pages]);
}, [fileMap, searchMessages?.pages]);
if (!searchQuery || !searchQueryRes?.data) {
return null;
}
useEffect(() => {
if (isError && searchQuery) {
showToast({ message: 'An error occurred during search', status: 'error' });
}
}, [isError, searchQuery, showToast]);
if (isSearchTyping || searchQueryRes.isInitialLoading || searchQueryRes.isLoading) {
const isSearchLoading = search.isTyping || isLoading || isFetchingNextPage;
if (isSearchLoading) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="text-text-primary" />
@ -49,9 +64,13 @@ export default function Search() {
);
}
if (!searchQuery) {
return null;
}
return (
<MinimalMessagesWrapper ref={containerRef} className="relative flex h-full pt-4">
{(messages && messages.length == 0) || messages == null ? (
{(messages && messages.length === 0) || messages == null ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-lg bg-white p-6 text-lg text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
{localize('com_ui_nothing_found')}
@ -62,7 +81,7 @@ export default function Search() {
{messages.map((msg) => (
<SearchMessage key={msg.messageId} message={msg} />
))}
{searchQueryRes.isFetchingNextPage && (
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<Spinner className="text-text-primary" />
</div>

View file

@ -1,28 +1,24 @@
import { atom } from 'recoil';
const isSearchEnabled = atom<boolean | null>({
key: 'isSearchEnabled',
default: null,
});
export type SearchState = {
enabled: boolean | null;
query: string;
debouncedQuery: string;
isSearching: boolean;
isTyping: boolean;
};
const searchQuery = atom({
key: 'searchQuery',
default: '',
});
const isSearching = atom({
key: 'isSearching',
default: false,
});
const isSearchTyping = atom({
key: 'isSearchTyping',
default: false,
export const search = atom<SearchState>({
key: 'search',
default: {
enabled: null,
query: '',
debouncedQuery: '',
isSearching: false,
isTyping: false,
},
});
export default {
isSearchEnabled,
searchQuery,
isSearching,
isSearchTyping,
search,
};

View file

@ -2509,7 +2509,6 @@ html {
}
.popover-ui {
/* z-index: 1000; */
display: flex;
max-height: min(var(--popover-available-height, 1700px), 1700px);
flex-direction: column;

View file

@ -1,5 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
import type { TConversation, InfiniteData } from 'librechat-data-provider';
import { QueryClient, InfiniteData } from '@tanstack/react-query';
import type { TConversation } from 'librechat-data-provider';
import {
dateKeys,
storeEndpointSettings,
@ -14,7 +14,6 @@ import {
removeConvoFromAllQueries,
addConversationToAllConversationsQueries,
} from './convos';
import { convoData } from './convos.fakeData';
import { normalizeData } from './collection';
jest.mock('date-fns', () => {

View file

@ -1,4 +1,5 @@
import type { AssistantsEndpoint } from './schemas';
import * as q from './types/queries';
// Testing this buildQuery function
const buildQuery = (params: Record<string, unknown>): string => {
@ -28,8 +29,19 @@ export const userPlugins = () => '/api/user/plugins';
export const deleteUser = () => '/api/user/delete';
export const messages = (conversationId: string, messageId?: string) =>
`/api/messages/${conversationId}${messageId != null && messageId ? `/${messageId}` : ''}`;
export const messages = (params: q.MessagesListParams) => {
const { conversationId, messageId, ...rest } = params;
if (conversationId && messageId) {
return `/api/messages/${conversationId}/${messageId}`;
}
if (conversationId) {
return `/api/messages/${conversationId}`;
}
return `/api/messages${buildQuery(rest)}`;
};
const shareRoot = '/api/share';
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
@ -62,22 +74,7 @@ export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`;
export const conversationsRoot = '/api/convos';
export const conversations = (
isArchived?: boolean,
sortBy?: 'title' | 'createdAt' | 'updatedAt',
sortDirection?: 'asc' | 'desc',
tags?: string[],
search?: string,
cursor?: string,
) => {
const params = {
isArchived,
sortBy,
sortDirection,
tags,
search,
cursor,
};
export const conversations = (params: q.ConversationListParams) => {
return `${conversationsRoot}${buildQuery(params)}`;
};

View file

@ -30,16 +30,6 @@ export function deleteUser(): Promise<s.TPreset> {
return request.delete(endpoints.deleteUser());
}
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
if (
conversationId === config.Constants.NEW_CONVO ||
conversationId === config.Constants.PENDING_CONVO
) {
return Promise.resolve([]);
}
return request.get(endpoints.messages(conversationId));
}
export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesResponse> {
return request.get(endpoints.shareMessages(shareId));
}
@ -70,31 +60,6 @@ export function deleteSharedLink(shareId: string): Promise<m.TDeleteSharedLinkRe
return request.delete(endpoints.shareMessages(shareId));
}
export function updateMessage(payload: t.TUpdateMessageRequest): Promise<unknown> {
const { conversationId, messageId, text } = payload;
if (!conversationId) {
throw new Error('conversationId is required');
}
return request.put(endpoints.messages(conversationId, messageId), { text });
}
export const editArtifact = async ({
messageId,
...params
}: m.TEditArtifactRequest): Promise<m.TEditArtifactResponse> => {
return request.post(`/api/messages/artifact/${messageId}`, params);
};
export function updateMessageContent(payload: t.TUpdateMessageContent): Promise<unknown> {
const { conversationId, messageId, index, text } = payload;
if (!conversationId) {
throw new Error('conversationId is required');
}
return request.put(endpoints.messages(conversationId, messageId), { text, index });
}
export function updateUserKey(payload: t.TUpdateUserKeyRequest) {
const { value } = payload;
if (!value) {
@ -602,24 +567,11 @@ export function clearAllConversations(): Promise<unknown> {
export const listConversations = (
params?: q.ConversationListParams,
): Promise<q.ConversationListResponse> => {
const isArchived = params?.isArchived ?? false;
const sortBy = params?.sortBy;
const sortDirection = params?.sortDirection;
const tags = params?.tags || [];
const search = params?.search || '';
const cursor = params?.cursor;
if (search !== '' && isArchived === false) {
return request.get(endpoints.search(search, cursor));
} else {
return request.get(
endpoints.conversations(isArchived, sortBy, sortDirection, tags, search, cursor),
);
}
return request.get(endpoints.conversations(params ?? {}));
};
export function getConversations(cursor: string): Promise<t.TGetConversationsResponse> {
return request.get(endpoints.conversations(undefined, undefined, undefined, [], '', cursor));
return request.get(endpoints.conversations({ cursor }));
}
export function getConversationById(id: string): Promise<s.TConversation> {
@ -642,6 +594,45 @@ export function genTitle(payload: m.TGenTitleRequest): Promise<m.TGenTitleRespon
return request.post(endpoints.genTitle(), payload);
}
export const listMessages = (params?: q.MessagesListParams): Promise<q.MessagesListResponse> => {
return request.get(endpoints.messages(params ?? {}));
};
export function updateMessage(payload: t.TUpdateMessageRequest): Promise<unknown> {
const { conversationId, messageId, text } = payload;
if (!conversationId) {
throw new Error('conversationId is required');
}
return request.put(endpoints.messages({ conversationId, messageId }), { text });
}
export function updateMessageContent(payload: t.TUpdateMessageContent): Promise<unknown> {
const { conversationId, messageId, index, text } = payload;
if (!conversationId) {
throw new Error('conversationId is required');
}
return request.put(endpoints.messages({ conversationId, messageId }), { text, index });
}
export const editArtifact = async ({
messageId,
...params
}: m.TEditArtifactRequest): Promise<m.TEditArtifactResponse> => {
return request.post(`/api/messages/artifact/${messageId}`, params);
};
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
if (
conversationId === config.Constants.NEW_CONVO ||
conversationId === config.Constants.PENDING_CONVO
) {
return Promise.resolve([]);
}
return request.get(endpoints.messages({ conversationId }));
}
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
return request.get(endpoints.getPrompt(id));
}

View file

@ -27,21 +27,6 @@ export type MinimalConversation = Pick<
export type ConversationListResponse = {
conversations: MinimalConversation[];
mwssages?: s.TMessage[];
nextCursor: string | null;
};
export type SearchConversationListParams = {
nextCursor?: string | null;
pageSize?: number;
search: string;
};
export type SearchConversation = Pick<s.TConversation, 'conversationId' | 'title' | 'user'>;
export type SearchConversationListResponse = {
conversations: SearchConversation[];
messages: s.TMessage[];
nextCursor: string | null;
};
@ -51,6 +36,23 @@ export type ConversationUpdater = (
conversation: s.TConversation,
) => ConversationData;
/* Messages */
export type MessagesListParams = {
cursor?: string | null;
sortBy?: 'endpoint' | 'createdAt' | 'updatedAt';
sortDirection?: 'asc' | 'desc';
pageSize?: number;
conversationId?: string;
messageId?: string;
search?: string;
};
export type MessagesListResponse = {
messages: s.TMessage[];
nextCursor: string | null;
};
/* Shared Links */
export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
messages: s.TMessage[];
};