mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
📜 refactor: Optimize Conversation History Nav with Cursor Pagination (#5785)
* ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * ✨ feat: implement cursor-based pagination for conversations API * 🔧 refactor: remove createdAt from conversation selection in API and type definitions * 🔧 refactor: include createdAt in conversation selection and update related types * ✨ fix: search functionality and bugs with loadMoreConversations * feat: move ArchivedChats to cursor and DataTable standard * 🔧 refactor: add InfiniteQueryObserverResult type import in Nav component * feat: enhance conversation listing with pagination, sorting, and search capabilities * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: remove unused translation keys for archived chats and search results * 🔧 fix: Archived Chats, Delete Convo, Duplicate Convo * 🔧 refactor: improve conversation components with layout adjustments and new translations * 🔧 refactor: simplify archive conversation mutation and improve unarchive handling; fix: update fork mutation * 🔧 refactor: decode search query parameter in conversation route; improve error handling in unarchive mutation; clean up DataTable component styles * 🔧 refactor: remove unused translation key for empty archived chats * 🚀 fix: `archivedConversation` query key not updated correctly while archiving * 🧠 feat: Bedrock Anthropic Reasoning & Update Endpoint Handling (#6163) * feat: Add thinking and thinkingBudget parameters for Bedrock Anthropic models * chore: Update @librechat/agents to version 2.1.8 * refactor: change region order in params * refactor: Add maxTokens parameter to conversation preset schema * refactor: Update agent client to use bedrockInputSchema and improve error handling for model parameters * refactor: streamline/optimize llmConfig initialization and saving for bedrock * fix: ensure config titleModel is used for all endpoints * refactor: enhance OpenAIClient and agent initialization to support endpoint checks for OpenRouter * chore: bump @google/generative-ai * ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: update translation keys for clarity; simplify conversation query parameters and improve sorting functionality in SharedLinks component * 🔧 refactor: optimize conversation loading logic and improve search handling in Nav component * fix: package-lock * fix: package-lock 2 * fix: package lock 3 * refactor: remove unused utility files and exports to clean up the codebase * refactor: remove i18n and useAuthRedirect modules to streamline codebase * refactor: optimize Conversations component and remove unused ToggleContext * refactor(Convo): add RenameForm and ConvoLink components; enhance Conversations component with responsive design * fix: add missing @azure/storage-blob dependency in package.json * refactor(Search): add error handling with toast notification for search errors * refactor: make createdAt and updatedAt fields of tConvoUpdateSchema less restrictive if timestamps are missing * chore: update @azure/storage-blob dependency to version 12.27.0, ensure package-lock is correct * refactor(Search): improve conversation handling server side * fix: eslint warning and errors * refactor(Search): improved search loading state and overall UX * Refactors conversation cache management Centralizes conversation mutation logic into dedicated utility functions for adding, updating, and removing conversations from query caches. Improves reliability and maintainability by: - Consolidating duplicate cache manipulation code - Adding type safety for infinite query data structures - Implementing consistent cache update patterns across all conversation operations - Removing obsolete conversation helper functions in favor of standardized utilities * fix: conversation handling and SSE event processing - Optimizes conversation state management with useMemo and proper hook ordering - Improves SSE event handler documentation and error handling - Adds reset guard flag for conversation changes - Removes redundant navigation call - Cleans up cursor handling logic and document structure Improves code maintainability and prevents potential race conditions in conversation state updates * refactor: add type for SearchBar `onChange` * fix: type tags * style: rounded to xl all Header buttons * fix: activeConvo in Convo not working * style(Bookmarks): improved UI * a11y(AccountSettings): fixed hover style not visible when using light theme * style(SettingsTabs): improved tab switchers and dropdowns * feat: add translations keys for Speech * chore: fix package-lock * fix(mutations): legacy import after rebase * feat: refactor conversation navigation for accessibility * fix(search): convo and message create/update date not returned * fix(search): show correct iconURL and endpoint for searched messages * fix: small UI improvements * chore: console.log cleanup * chore: fix tests * fix(ChatForm): improve conversation ID handling and clean up useMemo dependencies * chore: improve typing * chore: improve typing * fix(useSSE): clear conversation ID on submission to prevent draft restoration * refactor(OpenAIClient): clean up abort handler * refactor(abortMiddleware): change handleAbort to use function expression * feat: add PENDING_CONVO constant and update conversation ID checks * fix: final event handling on abort * fix: improve title sync and query cache sync on final event * fix: prevent overwriting cached conversation data if it already exists --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
77a21719fd
commit
650e9b4f6c
69 changed files with 3434 additions and 2139 deletions
|
|
@ -1469,6 +1469,11 @@ ${convo}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (openai.abortHandler && abortController.signal) {
|
||||||
|
abortController.signal.removeEventListener('abort', openai.abortHandler);
|
||||||
|
openai.abortHandler = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!chatCompletion && UnexpectedRoleError) {
|
if (!chatCompletion && UnexpectedRoleError) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
|
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,13 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
|
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
|
||||||
try {
|
try {
|
||||||
if (metadata && metadata?.context) {
|
if (metadata?.context) {
|
||||||
logger.debug(`[saveConvo] ${metadata.context}`);
|
logger.debug(`[saveConvo] ${metadata.context}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages({ conversationId }, '_id');
|
const messages = await getMessages({ conversationId }, '_id');
|
||||||
const update = { ...convo, messages, user: req.user.id };
|
const update = { ...convo, messages, user: req.user.id };
|
||||||
|
|
||||||
if (newConversationId) {
|
if (newConversationId) {
|
||||||
update.conversationId = newConversationId;
|
update.conversationId = newConversationId;
|
||||||
}
|
}
|
||||||
|
|
@ -148,75 +150,100 @@ module.exports = {
|
||||||
throw new Error('Failed to save conversations in bulk.');
|
throw new Error('Failed to save conversations in bulk.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => {
|
getConvosByCursor: async (
|
||||||
const query = { user };
|
user,
|
||||||
|
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||||
|
) => {
|
||||||
|
const filters = [{ user }];
|
||||||
|
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
query.isArchived = true;
|
filters.push({ isArchived: true });
|
||||||
} else {
|
} else {
|
||||||
query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }];
|
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
|
||||||
}
|
|
||||||
if (Array.isArray(tags) && tags.length > 0) {
|
|
||||||
query.tags = { $in: tags };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }];
|
if (Array.isArray(tags) && tags.length > 0) {
|
||||||
|
filters.push({ tags: { $in: tags } });
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
try {
|
||||||
|
const meiliResults = await Conversation.meiliSearch(search);
|
||||||
|
const matchingIds = Array.isArray(meiliResults.hits)
|
||||||
|
? meiliResults.hits.map((result) => result.conversationId)
|
||||||
|
: [];
|
||||||
|
if (!matchingIds.length) {
|
||||||
|
return { conversations: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
filters.push({ conversationId: { $in: matchingIds } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||||
|
return { message: 'Error during meiliSearch' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const totalConvos = (await Conversation.countDocuments(query)) || 1;
|
|
||||||
const totalPages = Math.ceil(totalConvos / pageSize);
|
|
||||||
const convos = await Conversation.find(query)
|
const convos = await Conversation.find(query)
|
||||||
.sort({ updatedAt: -1 })
|
.select('conversationId endpoint title createdAt updatedAt user')
|
||||||
.skip((pageNumber - 1) * pageSize)
|
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||||
.limit(pageSize)
|
.limit(limit + 1)
|
||||||
.lean();
|
.lean();
|
||||||
return { conversations: convos, pages: totalPages, pageNumber, pageSize };
|
|
||||||
|
let nextCursor = null;
|
||||||
|
if (convos.length > limit) {
|
||||||
|
const lastConvo = convos.pop();
|
||||||
|
nextCursor = lastConvo.updatedAt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conversations: convos, nextCursor };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByPage] Error getting conversations', error);
|
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||||
return { message: 'Error getting conversations' };
|
return { message: 'Error getting conversations' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
|
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||||
try {
|
try {
|
||||||
if (!convoIds || convoIds.length === 0) {
|
if (!convoIds?.length) {
|
||||||
return { conversations: [], pages: 1, pageNumber, pageSize };
|
return { conversations: [], nextCursor: null, convoMap: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationIds = convoIds.map((convo) => convo.conversationId);
|
||||||
|
|
||||||
|
const results = await Conversation.find({
|
||||||
|
user,
|
||||||
|
conversationId: { $in: conversationIds },
|
||||||
|
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||||
|
|
||||||
|
let filtered = results;
|
||||||
|
if (cursor && cursor !== 'start') {
|
||||||
|
const cursorDate = new Date(cursor);
|
||||||
|
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = filtered.slice(0, limit + 1);
|
||||||
|
let nextCursor = null;
|
||||||
|
if (limited.length > limit) {
|
||||||
|
const lastConvo = limited.pop();
|
||||||
|
nextCursor = lastConvo.updatedAt.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = {};
|
|
||||||
const convoMap = {};
|
const convoMap = {};
|
||||||
const promises = [];
|
limited.forEach((convo) => {
|
||||||
|
|
||||||
convoIds.forEach((convo) =>
|
|
||||||
promises.push(
|
|
||||||
Conversation.findOne({
|
|
||||||
user,
|
|
||||||
conversationId: convo.conversationId,
|
|
||||||
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
|
|
||||||
}).lean(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = (await Promise.all(promises)).filter(Boolean);
|
|
||||||
|
|
||||||
results.forEach((convo, i) => {
|
|
||||||
const page = Math.floor(i / pageSize) + 1;
|
|
||||||
if (!cache[page]) {
|
|
||||||
cache[page] = [];
|
|
||||||
}
|
|
||||||
cache[page].push(convo);
|
|
||||||
convoMap[convo.conversationId] = convo;
|
convoMap[convo.conversationId] = convo;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(results.length / pageSize);
|
return { conversations: limited, nextCursor, convoMap };
|
||||||
cache.pages = totalPages;
|
|
||||||
cache.pageSize = pageSize;
|
|
||||||
return {
|
|
||||||
cache,
|
|
||||||
conversations: cache[pageNumber] || [],
|
|
||||||
pages: totalPages || 1,
|
|
||||||
pageNumber,
|
|
||||||
pageSize,
|
|
||||||
convoMap,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||||
return { message: 'Error fetching conversations' };
|
return { message: 'Error fetching conversations' };
|
||||||
|
|
@ -257,10 +284,26 @@ module.exports = {
|
||||||
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
|
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
|
||||||
*/
|
*/
|
||||||
deleteConvos: async (user, filter) => {
|
deleteConvos: async (user, filter) => {
|
||||||
let toRemove = await Conversation.find({ ...filter, user }).select('conversationId');
|
try {
|
||||||
const ids = toRemove.map((instance) => instance.conversationId);
|
const userFilter = { ...filter, user };
|
||||||
let deleteCount = await Conversation.deleteMany({ ...filter, user });
|
|
||||||
deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } });
|
const conversations = await Conversation.find(userFilter).select('conversationId');
|
||||||
return deleteCount;
|
const conversationIds = conversations.map((c) => c.conversationId);
|
||||||
|
|
||||||
|
if (!conversationIds.length) {
|
||||||
|
throw new Error('Conversation not found or already deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteConvoResult = await Conversation.deleteMany(userFilter);
|
||||||
|
|
||||||
|
const deleteMessagesResult = await deleteMessages({
|
||||||
|
conversationId: { $in: conversationIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteConvos] Error deleting conversations and messages', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||||
"@azure/identity": "^4.7.0",
|
"@azure/identity": "^4.7.0",
|
||||||
"@azure/search-documents": "^12.0.0",
|
"@azure/search-documents": "^12.0.0",
|
||||||
"@azure/storage-blob": "^12.26.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@google/generative-ai": "^0.23.0",
|
"@google/generative-ai": "^0.23.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"@langchain/google-genai": "^0.2.2",
|
"@langchain/google-genai": "^0.2.2",
|
||||||
"@langchain/google-vertexai": "^0.2.3",
|
"@langchain/google-vertexai": "^0.2.3",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.4.17",
|
"@librechat/agents": "^2.4.20",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,8 @@ async function abortMessage(req, res) {
|
||||||
res.send(JSON.stringify(finalEvent));
|
res.send(JSON.stringify(finalEvent));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAbort = () => {
|
const handleAbort = function () {
|
||||||
return async (req, res) => {
|
return async function (req, res) {
|
||||||
try {
|
try {
|
||||||
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
|
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
|
||||||
await clearPendingReq({ userId: req.user.id });
|
await clearPendingReq({ userId: req.user.id });
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { importConversations } = require('~/server/utils/import');
|
const { importConversations } = require('~/server/utils/import');
|
||||||
const { createImportLimiters } = require('~/server/middleware');
|
const { createImportLimiters } = require('~/server/middleware');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
|
const { isEnabled, sleep } = require('~/server/utils');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { sleep } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const assistantClients = {
|
const assistantClients = {
|
||||||
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
||||||
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
|
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
|
||||||
|
|
@ -20,28 +21,30 @@ const router = express.Router();
|
||||||
router.use(requireJwtAuth);
|
router.use(requireJwtAuth);
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
let pageNumber = req.query.pageNumber || 1;
|
const limit = parseInt(req.query.limit, 10) || 25;
|
||||||
pageNumber = parseInt(pageNumber, 10);
|
const cursor = req.query.cursor;
|
||||||
|
const isArchived = isEnabled(req.query.isArchived);
|
||||||
|
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||||
|
const order = req.query.order || 'desc';
|
||||||
|
|
||||||
if (isNaN(pageNumber) || pageNumber < 1) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page number' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageSize = req.query.pageSize || 25;
|
|
||||||
pageSize = parseInt(pageSize, 10);
|
|
||||||
|
|
||||||
if (isNaN(pageSize) || pageSize < 1) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page size' });
|
|
||||||
}
|
|
||||||
const isArchived = req.query.isArchived === 'true';
|
|
||||||
let tags;
|
let tags;
|
||||||
if (req.query.tags) {
|
if (req.query.tags) {
|
||||||
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
|
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
|
||||||
} else {
|
|
||||||
tags = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
|
try {
|
||||||
|
const result = await getConvosByCursor(req.user.id, {
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
isArchived,
|
||||||
|
tags,
|
||||||
|
search,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Error fetching conversations' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:conversationId', async (req, res) => {
|
router.get('/:conversationId', async (req, res) => {
|
||||||
|
|
@ -76,22 +79,28 @@ router.post('/gen_title', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/clear', async (req, res) => {
|
router.delete('/', async (req, res) => {
|
||||||
let filter = {};
|
let filter = {};
|
||||||
const { conversationId, source, thread_id, endpoint } = req.body.arg;
|
const { conversationId, source, thread_id, endpoint } = req.body.arg;
|
||||||
if (conversationId) {
|
|
||||||
filter = { conversationId };
|
// Prevent deletion of all conversations
|
||||||
|
if (!conversationId && !source && !thread_id && !endpoint) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'no parameters provided',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source === 'button' && !conversationId) {
|
if (conversationId) {
|
||||||
|
filter = { conversationId };
|
||||||
|
} else if (source === 'button') {
|
||||||
return res.status(200).send('No conversationId provided');
|
return res.status(200).send('No conversationId provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof endpoint != 'undefined' &&
|
typeof endpoint !== 'undefined' &&
|
||||||
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
|
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
|
||||||
) {
|
) {
|
||||||
/** @type {{ openai: OpenAI}} */
|
/** @type {{ openai: OpenAI }} */
|
||||||
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
|
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
|
||||||
try {
|
try {
|
||||||
const response = await openai.beta.threads.del(thread_id);
|
const response = await openai.beta.threads.del(thread_id);
|
||||||
|
|
@ -101,9 +110,6 @@ router.post('/clear', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// for debugging deletion source
|
|
||||||
// logger.debug('source:', source);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dbResponse = await deleteConvos(req.user.id, filter);
|
const dbResponse = await deleteConvos(req.user.id, filter);
|
||||||
await deleteToolCalls(req.user.id, filter.conversationId);
|
await deleteToolCalls(req.user.id, filter.conversationId);
|
||||||
|
|
@ -114,6 +120,17 @@ router.post('/clear', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete('/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbResponse = await deleteConvos(req.user.id, {});
|
||||||
|
await deleteToolCalls(req.user.id);
|
||||||
|
res.status(201).json(dbResponse);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error clearing conversations', error);
|
||||||
|
res.status(500).send('Error clearing conversations');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/update', async (req, res) => {
|
router.post('/update', async (req, res) => {
|
||||||
const update = req.body.arg;
|
const update = req.body.arg;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ const { MeiliSearch } = require('meilisearch');
|
||||||
const { Conversation, getConvosQueried } = require('~/models/Conversation');
|
const { Conversation, getConvosQueried } = require('~/models/Conversation');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
||||||
|
const { Message, getMessage } = require('~/models/Message');
|
||||||
const { reduceHits } = require('~/lib/utils/reduceHits');
|
const { reduceHits } = require('~/lib/utils/reduceHits');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { Message } = require('~/models/Message');
|
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
const keyvRedis = require('~/cache/keyvRedis');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
|
@ -27,25 +27,24 @@ router.get('/sync', async function (req, res) {
|
||||||
|
|
||||||
router.get('/', async function (req, res) {
|
router.get('/', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
let user = req.user.id ?? '';
|
const user = req.user.id ?? '';
|
||||||
const { q } = req.query;
|
const { q, cursor = 'start' } = req.query;
|
||||||
const pageNumber = req.query.pageNumber || 1;
|
const key = `${user}:search:${q}:${cursor}`;
|
||||||
const key = `${user}:search:${q}`;
|
|
||||||
const cached = await cache.get(key);
|
const cached = await cache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
logger.debug('[/search] cache hit: ' + key);
|
logger.debug('[/search] cache hit: ' + key);
|
||||||
const { pages, pageSize, messages } = cached;
|
return res.status(200).send(cached);
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.send({ conversations: cached[pageNumber], pages, pageNumber, pageSize, messages });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = (await Message.meiliSearch(q, undefined, true)).hits;
|
const [messageResults, titleResults] = await Promise.all([
|
||||||
const titles = (await Conversation.meiliSearch(q)).hits;
|
Message.meiliSearch(q, undefined, true),
|
||||||
|
Conversation.meiliSearch(q),
|
||||||
|
]);
|
||||||
|
const messages = messageResults.hits;
|
||||||
|
const titles = titleResults.hits;
|
||||||
|
|
||||||
const sortedHits = reduceHits(messages, titles);
|
const sortedHits = reduceHits(messages, titles);
|
||||||
const result = await getConvosQueried(user, sortedHits, pageNumber);
|
const result = await getConvosQueried(user, sortedHits, cursor);
|
||||||
|
|
||||||
const activeMessages = [];
|
const activeMessages = [];
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
|
@ -55,20 +54,53 @@ router.get('/', async function (req, res) {
|
||||||
}
|
}
|
||||||
if (result.convoMap[message.conversationId]) {
|
if (result.convoMap[message.conversationId]) {
|
||||||
const convo = result.convoMap[message.conversationId];
|
const convo = result.convoMap[message.conversationId];
|
||||||
const { title, chatGptLabel, model } = convo;
|
|
||||||
message = { ...message, ...{ title, chatGptLabel, model } };
|
const dbMessage = await getMessage({ user, messageId: message.messageId });
|
||||||
activeMessages.push(message);
|
activeMessages.push({
|
||||||
|
...message,
|
||||||
|
title: convo.title,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
model: convo.model,
|
||||||
|
isCreatedByUser: dbMessage?.isCreatedByUser,
|
||||||
|
endpoint: dbMessage?.endpoint,
|
||||||
|
iconURL: dbMessage?.iconURL,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.messages = activeMessages;
|
|
||||||
|
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) {
|
if (result.cache) {
|
||||||
result.cache.messages = activeMessages;
|
result.cache.messages = activeMessages;
|
||||||
|
result.cache.conversations = activeConversations;
|
||||||
cache.set(key, result.cache, expiration);
|
cache.set(key, result.cache, expiration);
|
||||||
delete result.cache;
|
|
||||||
}
|
}
|
||||||
delete result.convoMap;
|
|
||||||
|
|
||||||
res.status(200).send(result);
|
const response = {
|
||||||
|
nextCursor: result.nextCursor ?? null,
|
||||||
|
messages: activeMessages,
|
||||||
|
conversations: activeConversations,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).send(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/search] Error while searching messages & conversations', error);
|
logger.error('[/search] Error while searching messages & conversations', error);
|
||||||
res.status(500).send({ message: 'Error searching' });
|
res.status(500).send({ message: 'Error searching' });
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/node": "^20.3.0",
|
"@types/node": "^20.3.0",
|
||||||
"@types/react": "^18.2.11",
|
"@types/react": "^18.2.11",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import useSearch from '~/hooks/Conversations/useSearch';
|
import { UseSearchMessagesResult } from '~/hooks/Conversations/useSearch';
|
||||||
type SearchContextType = ReturnType<typeof useSearch>;
|
|
||||||
|
|
||||||
export const SearchContext = createContext<SearchContextType>({} as SearchContextType);
|
export const SearchContext = createContext<UseSearchMessagesResult>({} as UseSearchMessagesResult);
|
||||||
export const useSearchContext = () => useContext(SearchContext);
|
export const useSearchContext = () => useContext(SearchContext);
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,8 @@ export type TResData = TBaseResData & {
|
||||||
responseMessage: t.TMessage;
|
responseMessage: t.TMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TFinalResData = TBaseResData & {
|
export type TFinalResData = Omit<TBaseResData, 'conversation'> & {
|
||||||
|
conversation: Partial<t.TConversation> & Pick<t.TConversation, 'conversationId'>;
|
||||||
requestMessage?: t.TMessage;
|
requestMessage?: t.TMessage;
|
||||||
responseMessage?: t.TMessage;
|
responseMessage?: t.TMessage;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function BrowserVoiceDropdown() {
|
||||||
onChange={handleVoiceChange}
|
onChange={handleVoiceChange}
|
||||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||||
testId="BrowserVoiceDropdown"
|
testId="BrowserVoiceDropdown"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -57,6 +58,7 @@ export function ExternalVoiceDropdown() {
|
||||||
onChange={handleVoiceChange}
|
onChange={handleVoiceChange}
|
||||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||||
testId="ExternalVoiceDropdown"
|
testId="ExternalVoiceDropdown"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,19 +34,22 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
|
||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Spinner className="size-4" />;
|
return <Spinner className="size-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return <BookmarkFilledIcon className="size-4" />;
|
return <BookmarkFilledIcon className="size-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BookmarkIcon className="size-4" />;
|
return <BookmarkIcon className="size-4" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={tag as string}
|
aria-label={tag as string}
|
||||||
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-secondary data-[focus]:ring-2 data-[focus]:ring-primary"
|
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-hover data-[focus-visible]:ring-2 data-[focus-visible]:ring-primary"
|
||||||
{...rest}
|
{...rest}
|
||||||
as="button"
|
as="button"
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ function AddMultiConvo() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const clickHandler = () => {
|
const clickHandler = () => {
|
||||||
|
|
||||||
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
||||||
setAddedConvo({
|
setAddedConvo({
|
||||||
...convo,
|
...convo,
|
||||||
|
|
@ -42,7 +41,7 @@ function AddMultiConvo() {
|
||||||
role="button"
|
role="button"
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
data-testid="parameters-button"
|
data-testid="parameters-button"
|
||||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} aria-label="Plus Icon" />
|
<PlusCircle size={16} aria-label="Plus Icon" />
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export default function ExportAndShareMenu({
|
||||||
<Ariakit.MenuButton
|
<Ariakit.MenuButton
|
||||||
id="export-menu-button"
|
id="export-menu-button"
|
||||||
aria-label="Export options"
|
aria-label="Export options"
|
||||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
className="icon-md text-text-secondary"
|
className="icon-md text-text-secondary"
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
() => conversation?.endpointType ?? conversation?.endpoint,
|
() => conversation?.endpointType ?? conversation?.endpoint,
|
||||||
[conversation?.endpointType, conversation?.endpoint],
|
[conversation?.endpointType, conversation?.endpoint],
|
||||||
);
|
);
|
||||||
|
const conversationId = useMemo(
|
||||||
|
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||||
|
[conversation?.conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
|
const isRTL = useMemo(
|
||||||
|
() => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false),
|
||||||
|
[chatDirection],
|
||||||
|
);
|
||||||
const invalidAssistant = useMemo(
|
const invalidAssistant = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isAssistantsEndpoint(endpoint) &&
|
isAssistantsEndpoint(endpoint) &&
|
||||||
|
|
@ -110,10 +117,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
}, [isCollapsed]);
|
}, [isCollapsed]);
|
||||||
|
|
||||||
useAutoSave({
|
useAutoSave({
|
||||||
conversationId: conversation?.conversationId,
|
|
||||||
textAreaRef,
|
|
||||||
files,
|
files,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
textAreaRef,
|
||||||
|
conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||||
|
|
@ -166,7 +173,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
const handleSaveBadges = useCallback(() => {
|
const handleSaveBadges = useCallback(() => {
|
||||||
setIsEditingBadges(false);
|
setIsEditingBadges(false);
|
||||||
setBackupBadges([]);
|
setBackupBadges([]);
|
||||||
}, []);
|
}, [setIsEditingBadges, setBackupBadges]);
|
||||||
|
|
||||||
const handleCancelBadges = useCallback(() => {
|
const handleCancelBadges = useCallback(() => {
|
||||||
if (backupBadges.length > 0) {
|
if (backupBadges.length > 0) {
|
||||||
|
|
@ -174,7 +181,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
}
|
}
|
||||||
setIsEditingBadges(false);
|
setIsEditingBadges(false);
|
||||||
setBackupBadges([]);
|
setBackupBadges([]);
|
||||||
}, [backupBadges, setBadges]);
|
}, [backupBadges, setBadges, setIsEditingBadges]);
|
||||||
|
|
||||||
const isMoreThanThreeRows = visualRowCount > 3;
|
const isMoreThanThreeRows = visualRowCount > 3;
|
||||||
|
|
||||||
|
|
@ -195,8 +202,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||||
centerFormOnLanding &&
|
centerFormOnLanding &&
|
||||||
(!conversation?.conversationId || conversation?.conversationId === Constants.NEW_CONVO) &&
|
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||||
!isSubmitting
|
!isSubmitting &&
|
||||||
|
conversation?.messages?.length === 0
|
||||||
? 'transition-all duration-200 sm:mb-28'
|
? 'transition-all duration-200 sm:mb-28'
|
||||||
: 'sm:mb-10',
|
: 'sm:mb-10',
|
||||||
)}
|
)}
|
||||||
|
|
@ -290,7 +298,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
</div>
|
</div>
|
||||||
<BadgeRow
|
<BadgeRow
|
||||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||||
conversationId={conversation?.conversationId ?? Constants.NEW_CONVO}
|
conversationId={conversationId}
|
||||||
onChange={setBadges}
|
onChange={setBadges}
|
||||||
isInChat={
|
isInChat={
|
||||||
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
|
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ const BookmarkMenu: FC = () => {
|
||||||
id="bookmark-menu-button"
|
id="bookmark-menu-button"
|
||||||
aria-label={localize('com_ui_bookmarks_add')}
|
aria-label={localize('com_ui_bookmarks_add')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
||||||
isMenuOpen ? 'bg-surface-hover' : '',
|
isMenuOpen ? 'bg-surface-hover' : '',
|
||||||
)}
|
)}
|
||||||
data-testid="bookmark-menu"
|
data-testid="bookmark-menu"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const PresetsMenu: FC = () => {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
data-testid="presets-button"
|
data-testid="presets-button"
|
||||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<BookCopy size={16} aria-label="Preset Icon" />
|
<BookCopy size={16} aria-label="Preset Icon" />
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Link } from 'lucide-react';
|
import { Link } from 'lucide-react';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useLocalize, useNavigateToConvo } from '~/hooks';
|
import { useLocalize, useNavigateToConvo } from '~/hooks';
|
||||||
|
import { findConversationInInfinite } from '~/utils';
|
||||||
import { useSearchContext } from '~/Providers';
|
import { useSearchContext } from '~/Providers';
|
||||||
import { getConversationById } from '~/utils';
|
|
||||||
|
|
||||||
export default function SearchButtons({ message }: { message: TMessage }) {
|
export default function SearchButtons({ message }: { message: TMessage }) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -17,7 +17,7 @@ export default function SearchButtons({ message }: { message: TMessage }) {
|
||||||
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const conversation = getConversationById(searchQueryRes?.data, conversationId);
|
const conversation = findConversationInInfinite(searchQueryRes?.data, conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,30 @@ import SubRow from './SubRow';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function Message({ message }: Pick<TMessageProps, 'message'>) {
|
const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
|
||||||
|
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||||
|
<div className="pt-0.5">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
<Icon iconData={iconData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageBody = ({ message, messageLabel, fontSize }) => (
|
||||||
|
<div
|
||||||
|
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
|
||||||
|
>
|
||||||
|
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||||
|
<SearchContent message={message} />
|
||||||
|
<SubRow classes="text-xs">
|
||||||
|
<MinimalHoverButtons message={message} />
|
||||||
|
<SearchButtons message={message} />
|
||||||
|
</SubRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
|
||||||
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
||||||
const fontSize = useRecoilValue(store.fontSize);
|
const fontSize = useRecoilValue(store.fontSize);
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
|
|
@ -18,60 +41,42 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
|
||||||
|
|
||||||
const iconData: TMessageIcon = useMemo(
|
const iconData: TMessageIcon = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
endpoint: message?.endpoint,
|
endpoint: message?.endpoint ?? '',
|
||||||
model: message?.model,
|
model: message?.model ?? '',
|
||||||
iconURL: message?.iconURL ?? '',
|
iconURL: message?.iconURL ?? '',
|
||||||
isCreatedByUser: message?.isCreatedByUser,
|
isCreatedByUser: message?.isCreatedByUser ?? false,
|
||||||
}),
|
}),
|
||||||
[message?.model, message?.iconURL, message?.endpoint, message?.isCreatedByUser],
|
[message?.endpoint, message?.model, message?.iconURL, message?.isCreatedByUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const messageLabel = useMemo(() => {
|
||||||
|
if (message?.isCreatedByUser) {
|
||||||
|
return UsernameDisplay
|
||||||
|
? (user?.name ?? '') || (user?.username ?? '')
|
||||||
|
: localize('com_user_message');
|
||||||
|
}
|
||||||
|
return message?.sender ?? '';
|
||||||
|
}, [
|
||||||
|
message?.isCreatedByUser,
|
||||||
|
message?.sender,
|
||||||
|
UsernameDisplay,
|
||||||
|
user?.name,
|
||||||
|
user?.username,
|
||||||
|
localize,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isCreatedByUser } = message;
|
|
||||||
|
|
||||||
let messageLabel = '';
|
|
||||||
if (isCreatedByUser) {
|
|
||||||
messageLabel = UsernameDisplay
|
|
||||||
? (user?.name ?? '') || (user?.username ?? '')
|
|
||||||
: localize('com_user_message');
|
|
||||||
} else {
|
|
||||||
messageLabel = message.sender ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="text-token-text-primary w-full bg-transparent">
|
||||||
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
|
<div className="m-auto p-4 py-2 md:gap-6">
|
||||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
<MessageAvatar iconData={iconData} />
|
||||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
|
||||||
<div>
|
|
||||||
<div className="pt-0.5">
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
|
||||||
<Icon iconData={iconData} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
|
||||||
>
|
|
||||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
|
||||||
<div className="flex-col gap-1 md:gap-3">
|
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
|
||||||
<SearchContent message={message} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SubRow classes="text-xs">
|
|
||||||
<MinimalHoverButtons message={message} />
|
|
||||||
<SearchButtons message={message} />
|
|
||||||
</SubRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,34 +37,28 @@ export function TemporaryChat() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-wrap items-center gap-2">
|
<div className="relative flex flex-wrap items-center gap-2">
|
||||||
<div className="badge-icon h-full">
|
<TooltipAnchor
|
||||||
<TooltipAnchor
|
description={localize(temporaryBadge.label)}
|
||||||
description={localize(temporaryBadge.label)}
|
render={
|
||||||
render={
|
<button
|
||||||
<motion.button
|
onClick={handleBadgeToggle}
|
||||||
onClick={handleBadgeToggle}
|
|
||||||
aria-label={localize(temporaryBadge.label)}
|
aria-label={localize(temporaryBadge.label)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
||||||
isTemporary
|
isTemporary
|
||||||
? 'bg-surface-active shadow-md'
|
? 'bg-surface-active shadow-md'
|
||||||
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
|
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||||
'active:scale-95 active:shadow-inner',
|
'active:shadow-inner',
|
||||||
)}
|
)}
|
||||||
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
>
|
||||||
>
|
{temporaryBadge.icon && (
|
||||||
{temporaryBadge.icon && (
|
<temporaryBadge.icon
|
||||||
<temporaryBadge.icon
|
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
|
||||||
className={cn(
|
/>
|
||||||
'relative h-5 w-5 md:h-4 md:w-4',
|
)}
|
||||||
!temporaryBadge.label && 'mx-auto',
|
</button>
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,203 @@
|
||||||
import { useMemo, memo } from 'react';
|
import { useMemo, memo, type FC, useCallback } from 'react';
|
||||||
|
import throttle from 'lodash/throttle';
|
||||||
import { parseISO, isToday } from 'date-fns';
|
import { parseISO, isToday } from 'date-fns';
|
||||||
|
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
||||||
|
import { useLocalize, TranslationKeys, useMediaQuery } from '~/hooks';
|
||||||
import { TConversation } from 'librechat-data-provider';
|
import { TConversation } from 'librechat-data-provider';
|
||||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
|
||||||
import { groupConversationsByDate } from '~/utils';
|
import { groupConversationsByDate } from '~/utils';
|
||||||
|
import { Spinner } from '~/components/svg';
|
||||||
import Convo from './Convo';
|
import Convo from './Convo';
|
||||||
|
|
||||||
const Conversations = ({
|
interface ConversationsProps {
|
||||||
conversations,
|
|
||||||
moveToTop,
|
|
||||||
toggleNav,
|
|
||||||
}: {
|
|
||||||
conversations: Array<TConversation | null>;
|
conversations: Array<TConversation | null>;
|
||||||
moveToTop: () => void;
|
moveToTop: () => void;
|
||||||
toggleNav: () => void;
|
toggleNav: () => void;
|
||||||
}) => {
|
containerRef: React.RefObject<HTMLDivElement | List>;
|
||||||
|
loadMoreConversations: () => void;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
isSearchLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingSpinner = memo(() => (
|
||||||
|
<Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-text-primary" />
|
||||||
|
));
|
||||||
|
|
||||||
|
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const groupedConversations = useMemo(
|
return (
|
||||||
() => groupConversationsByDate(conversations),
|
<div className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
|
||||||
[conversations],
|
{localize(groupName as TranslationKeys) || groupName}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DateLabel.displayName = 'DateLabel';
|
||||||
|
|
||||||
|
type FlattenedItem =
|
||||||
|
| { type: 'header'; groupName: string }
|
||||||
|
| { type: 'convo'; convo: TConversation };
|
||||||
|
|
||||||
|
const MemoizedConvo = memo(
|
||||||
|
({
|
||||||
|
conversation,
|
||||||
|
retainView,
|
||||||
|
toggleNav,
|
||||||
|
isLatestConvo,
|
||||||
|
}: {
|
||||||
|
conversation: TConversation;
|
||||||
|
retainView: () => void;
|
||||||
|
toggleNav: () => void;
|
||||||
|
isLatestConvo: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Convo
|
||||||
|
conversation={conversation}
|
||||||
|
retainView={retainView}
|
||||||
|
toggleNav={toggleNav}
|
||||||
|
isLatestConvo={isLatestConvo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
return (
|
||||||
|
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
|
||||||
|
prevProps.conversation.title === nextProps.conversation.title &&
|
||||||
|
prevProps.isLatestConvo === nextProps.isLatestConvo &&
|
||||||
|
prevProps.conversation.endpoint === nextProps.conversation.endpoint
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Conversations: FC<ConversationsProps> = ({
|
||||||
|
conversations: rawConversations,
|
||||||
|
moveToTop,
|
||||||
|
toggleNav,
|
||||||
|
containerRef,
|
||||||
|
loadMoreConversations,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isSearchLoading,
|
||||||
|
}) => {
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
const convoHeight = isSmallScreen ? 44 : 34;
|
||||||
|
|
||||||
|
const filteredConversations = useMemo(
|
||||||
|
() => rawConversations.filter(Boolean) as TConversation[],
|
||||||
|
[rawConversations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedConversations = useMemo(
|
||||||
|
() => groupConversationsByDate(filteredConversations),
|
||||||
|
[filteredConversations],
|
||||||
|
);
|
||||||
|
|
||||||
const firstTodayConvoId = useMemo(
|
const firstTodayConvoId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
||||||
?.conversationId,
|
?.conversationId ?? undefined,
|
||||||
[conversations],
|
[filteredConversations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flattenedItems = useMemo(() => {
|
||||||
|
const items: FlattenedItem[] = [];
|
||||||
|
groupedConversations.forEach(([groupName, convos]) => {
|
||||||
|
items.push({ type: 'header', groupName });
|
||||||
|
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, [groupedConversations]);
|
||||||
|
|
||||||
|
const cache = useMemo(
|
||||||
|
() =>
|
||||||
|
new CellMeasurerCache({
|
||||||
|
fixedWidth: true,
|
||||||
|
defaultHeight: convoHeight,
|
||||||
|
keyMapper: (index) => {
|
||||||
|
const item = flattenedItems[index];
|
||||||
|
return item.type === 'header' ? `header-${index}` : `convo-${item.convo.conversationId}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[flattenedItems, convoHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ index, key, parent, style }) => {
|
||||||
|
const item = flattenedItems[index];
|
||||||
|
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} />
|
||||||
|
) : (
|
||||||
|
<MemoizedConvo
|
||||||
|
conversation={item.convo}
|
||||||
|
retainView={moveToTop}
|
||||||
|
toggleNav={toggleNav}
|
||||||
|
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CellMeasurer>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRowHeight = useCallback(
|
||||||
|
({ index }: { index: number }) => cache.getHeight(index, 0),
|
||||||
|
[cache],
|
||||||
|
);
|
||||||
|
|
||||||
|
const throttledLoadMore = useMemo(
|
||||||
|
() => throttle(loadMoreConversations, 300),
|
||||||
|
[loadMoreConversations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRowsRendered = useCallback(
|
||||||
|
({ stopIndex }: { stopIndex: number }) => {
|
||||||
|
if (stopIndex >= flattenedItems.length - 2) {
|
||||||
|
throttledLoadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flattenedItems.length, throttledLoadMore],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-token-text-primary flex flex-col gap-2 pb-2 text-sm">
|
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
|
||||||
<div>
|
{isSearchLoading ? (
|
||||||
<span>
|
<div className="flex flex-1 items-center justify-center">
|
||||||
{groupedConversations.map(([groupName, convos]) => (
|
<Spinner className="text-text-primary" />
|
||||||
<div key={groupName}>
|
<span className="ml-2 text-text-primary">Loading...</span>
|
||||||
<div
|
</div>
|
||||||
className="text-text-secondary"
|
) : (
|
||||||
style={{
|
<div className="flex-1">
|
||||||
fontSize: '0.7rem',
|
<AutoSizer>
|
||||||
marginTop: '20px',
|
{({ width, height }) => (
|
||||||
marginBottom: '5px',
|
<List
|
||||||
paddingLeft: '10px',
|
ref={containerRef as React.RefObject<List>}
|
||||||
}}
|
width={width}
|
||||||
>
|
height={height}
|
||||||
{localize(groupName as TranslationKeys) || groupName}
|
deferredMeasurementCache={cache}
|
||||||
</div>
|
rowCount={flattenedItems.length}
|
||||||
{convos.map((convo, i) => (
|
rowHeight={getRowHeight}
|
||||||
<Convo
|
rowRenderer={rowRenderer}
|
||||||
key={`${groupName}-${convo.conversationId}-${i}`}
|
overscanRowCount={10}
|
||||||
isLatestConvo={convo.conversationId === firstTodayConvoId}
|
className="outline-none"
|
||||||
conversation={convo}
|
style={{ outline: 'none' }}
|
||||||
retainView={moveToTop}
|
role="list"
|
||||||
toggleNav={toggleNav}
|
aria-label="Conversations"
|
||||||
/>
|
onRowsRendered={handleRowsRendered}
|
||||||
))}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '5px',
|
|
||||||
marginBottom: '5px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
))}
|
</AutoSizer>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{isFetchingNextPage && !isSearchLoading && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,26 @@
|
||||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Check, X } from 'lucide-react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
import type { TConversation } from 'librechat-data-provider';
|
||||||
import { useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks';
|
import { useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks';
|
||||||
import { useUpdateConversationMutation } from '~/data-provider';
|
import { useUpdateConversationMutation } from '~/data-provider';
|
||||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useToastContext } from '~/Providers';
|
|
||||||
import { ConvoOptions } from './ConvoOptions';
|
import { ConvoOptions } from './ConvoOptions';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import RenameForm from './RenameForm';
|
||||||
|
import ConvoLink from './ConvoLink';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
type KeyEvent = KeyboardEvent<HTMLInputElement>;
|
interface ConversationProps {
|
||||||
|
|
||||||
type ConversationProps = {
|
|
||||||
conversation: TConversation;
|
conversation: TConversation;
|
||||||
retainView: () => void;
|
retainView: () => void;
|
||||||
toggleNav: () => void;
|
toggleNav: () => void;
|
||||||
isLatestConvo: boolean;
|
isLatestConvo: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function Conversation({
|
export default function Conversation({
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -31,27 +29,81 @@ export default function Conversation({
|
||||||
isLatestConvo,
|
isLatestConvo,
|
||||||
}: ConversationProps) {
|
}: ConversationProps) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
|
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
|
||||||
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
||||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
const { navigateWithLastTools } = useNavigateToConvo();
|
const { navigateWithLastTools } = useNavigateToConvo();
|
||||||
const { showToast } = useToastContext();
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const { conversationId, title } = conversation;
|
const { conversationId, title = '' } = conversation;
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [titleInput, setTitleInput] = useState(title);
|
const [titleInput, setTitleInput] = useState(title || '');
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
||||||
const localize = useLocalize();
|
|
||||||
|
|
||||||
const clickHandler = async (event: MouseEvent<HTMLAnchorElement>) => {
|
const previousTitle = useRef(title);
|
||||||
if (event.button === 0 && (event.ctrlKey || event.metaKey)) {
|
|
||||||
toggleNav();
|
useEffect(() => {
|
||||||
|
if (title !== previousTitle.current) {
|
||||||
|
setTitleInput(title as string);
|
||||||
|
previousTitle.current = title;
|
||||||
|
}
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
const isActiveConvo = useMemo(() => {
|
||||||
|
if (conversationId === Constants.NEW_CONVO) {
|
||||||
|
return currentConvoId === Constants.NEW_CONVO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentConvoId !== Constants.NEW_CONVO) {
|
||||||
|
return currentConvoId === conversationId;
|
||||||
|
} else {
|
||||||
|
const latestConvo = activeConvos?.[0];
|
||||||
|
return latestConvo === conversationId;
|
||||||
|
}
|
||||||
|
}, [currentConvoId, conversationId, activeConvos]);
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
setTitleInput(title as string);
|
||||||
|
setRenaming(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameSubmit = async (newTitle: string) => {
|
||||||
|
if (!conversationId || newTitle === title) {
|
||||||
|
setRenaming(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
try {
|
||||||
|
await updateConvoMutation.mutateAsync({
|
||||||
|
conversationId,
|
||||||
|
title: newTitle.trim() || localize('com_ui_untitled'),
|
||||||
|
});
|
||||||
|
setRenaming(false);
|
||||||
|
} catch (error) {
|
||||||
|
setTitleInput(title as string);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_rename_failed'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
showIcon: true,
|
||||||
|
});
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRename = () => {
|
||||||
|
setTitleInput(title as string);
|
||||||
|
setRenaming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigation = (ctrlOrMetaKey: boolean) => {
|
||||||
|
if (ctrlOrMetaKey) {
|
||||||
|
toggleNav();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentConvoId === conversationId || isPopoverActive) {
|
if (currentConvoId === conversationId || isPopoverActive) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -59,138 +111,68 @@ export default function Conversation({
|
||||||
|
|
||||||
toggleNav();
|
toggleNav();
|
||||||
|
|
||||||
// set document title
|
|
||||||
if (typeof title === 'string' && title.length > 0) {
|
if (typeof title === 'string' && title.length > 0) {
|
||||||
document.title = title;
|
document.title = title;
|
||||||
}
|
}
|
||||||
/* Note: Latest Message should not be reset if existing convo */
|
|
||||||
navigateWithLastTools(
|
navigateWithLastTools(
|
||||||
conversation,
|
conversation,
|
||||||
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
|
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameHandler = useCallback(() => {
|
const convoOptionsProps = {
|
||||||
setIsPopoverActive(false);
|
title,
|
||||||
setTitleInput(title);
|
retainView,
|
||||||
setRenaming(true);
|
renameHandler: handleRename,
|
||||||
}, [title]);
|
isActiveConvo,
|
||||||
|
conversationId,
|
||||||
useEffect(() => {
|
isPopoverActive,
|
||||||
if (renaming && inputRef.current) {
|
setIsPopoverActive,
|
||||||
inputRef.current.focus();
|
};
|
||||||
}
|
|
||||||
}, [renaming]);
|
|
||||||
|
|
||||||
const onRename = useCallback(
|
|
||||||
(e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setRenaming(false);
|
|
||||||
if (titleInput === title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof conversationId !== 'string' || conversationId === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConvoMutation.mutate(
|
|
||||||
{ conversationId, title: titleInput ?? '' },
|
|
||||||
{
|
|
||||||
onError: () => {
|
|
||||||
setTitleInput(title);
|
|
||||||
showToast({
|
|
||||||
message: 'Failed to rename conversation',
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
showIcon: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[title, titleInput, conversationId, showToast, updateConvoMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setTitleInput(title);
|
|
||||||
setRenaming(false);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
onRename(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[title, onRename],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelRename = useCallback(
|
|
||||||
(e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setTitleInput(title);
|
|
||||||
setRenaming(false);
|
|
||||||
},
|
|
||||||
[title],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isActiveConvo: boolean = useMemo(
|
|
||||||
() =>
|
|
||||||
currentConvoId === conversationId ||
|
|
||||||
(isLatestConvo &&
|
|
||||||
currentConvoId === 'new' &&
|
|
||||||
activeConvos[0] != null &&
|
|
||||||
activeConvos[0] !== 'new'),
|
|
||||||
[currentConvoId, conversationId, isLatestConvo, activeConvos],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
|
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||||
isSmallScreen ? 'h-12' : '',
|
|
||||||
)}
|
)}
|
||||||
|
role="listitem"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (renaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button === 0) {
|
||||||
|
handleNavigation(e.ctrlKey || e.metaKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (renaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleNavigation(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: renaming ? 'default' : 'pointer' }}
|
||||||
|
data-testid="convo-item"
|
||||||
>
|
>
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5">
|
<RenameForm
|
||||||
<input
|
titleInput={titleInput}
|
||||||
ref={inputRef}
|
setTitleInput={setTitleInput}
|
||||||
type="text"
|
onSubmit={handleRenameSubmit}
|
||||||
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
|
onCancel={handleCancelRename}
|
||||||
value={titleInput ?? ''}
|
localize={localize}
|
||||||
onChange={(e) => setTitleInput(e.target.value)}
|
/>
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={cancelRename}
|
|
||||||
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
|
|
||||||
>
|
|
||||||
<X
|
|
||||||
aria-hidden={true}
|
|
||||||
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onRename}
|
|
||||||
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
aria-hidden={true}
|
|
||||||
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<a
|
<ConvoLink
|
||||||
href={`/c/${conversationId}`}
|
isActiveConvo={isActiveConvo}
|
||||||
data-testid="convo-item"
|
title={title}
|
||||||
onClick={clickHandler}
|
onRename={handleRename}
|
||||||
className={cn(
|
isSmallScreen={isSmallScreen}
|
||||||
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
|
localize={localize}
|
||||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
|
||||||
)}
|
|
||||||
title={title ?? ''}
|
|
||||||
>
|
>
|
||||||
<EndpointIcon
|
<EndpointIcon
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
|
|
@ -198,23 +180,7 @@ export default function Conversation({
|
||||||
size={20}
|
size={20}
|
||||||
context="menu-item"
|
context="menu-item"
|
||||||
/>
|
/>
|
||||||
<div
|
</ConvoLink>
|
||||||
className="relative line-clamp-1 flex-1 grow overflow-hidden"
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setTitleInput(title);
|
|
||||||
setRenaming(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{isActiveConvo ? (
|
|
||||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%" />
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -224,17 +190,7 @@ export default function Conversation({
|
||||||
: 'hidden group-focus-within:flex group-hover:flex',
|
: 'hidden group-focus-within:flex group-hover:flex',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!renaming && (
|
{!renaming && <ConvoOptions {...convoOptionsProps} />}
|
||||||
<ConvoOptions
|
|
||||||
title={title}
|
|
||||||
retainView={retainView}
|
|
||||||
renameHandler={renameHandler}
|
|
||||||
isActiveConvo={isActiveConvo}
|
|
||||||
conversationId={conversationId}
|
|
||||||
isPopoverActive={isPopoverActive}
|
|
||||||
setIsPopoverActive={setIsPopoverActive}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
61
client/src/components/Conversations/ConvoLink.tsx
Normal file
61
client/src/components/Conversations/ConvoLink.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface ConvoLinkProps {
|
||||||
|
isActiveConvo: boolean;
|
||||||
|
title: string | null;
|
||||||
|
onRename: () => void;
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
localize: (key: any, options?: any) => string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConvoLink: React.FC<ConvoLinkProps> = ({
|
||||||
|
isActiveConvo,
|
||||||
|
title,
|
||||||
|
onRename,
|
||||||
|
isSmallScreen,
|
||||||
|
localize,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
|
||||||
|
isActiveConvo ? 'bg-surface-active-alt' : '',
|
||||||
|
)}
|
||||||
|
title={title ?? undefined}
|
||||||
|
aria-current={isActiveConvo ? 'page' : undefined}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ textOverflow: 'clip' }}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onRename();
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-label={isSmallScreen ? undefined : localize('com_ui_double_click_to_rename')}
|
||||||
|
>
|
||||||
|
{title || localize('com_ui_untitled')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
|
||||||
|
isActiveConvo
|
||||||
|
? 'from-surface-active-alt'
|
||||||
|
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%',
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConvoLink;
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { useState, useId, useRef, memo } from 'react';
|
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
|
||||||
import * as Menu from '@ariakit/react/menu';
|
import * as Menu from '@ariakit/react/menu';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
|
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import type * as t from '~/common';
|
import {
|
||||||
import { useDuplicateConversationMutation, useGetStartupConfig } from '~/data-provider';
|
useDuplicateConversationMutation,
|
||||||
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
|
useGetStartupConfig,
|
||||||
|
useArchiveConvoMutation,
|
||||||
|
} from '~/data-provider';
|
||||||
|
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
|
||||||
import { useToastContext, useChatContext } from '~/Providers';
|
import { useToastContext, useChatContext } from '~/Providers';
|
||||||
import { DropdownPopup } from '~/components/ui';
|
import { DropdownPopup, Spinner } from '~/components';
|
||||||
|
import { NotificationSeverity } from '~/common';
|
||||||
import DeleteButton from './DeleteButton';
|
import DeleteButton from './DeleteButton';
|
||||||
import ShareButton from './ShareButton';
|
import ShareButton from './ShareButton';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
@ -31,14 +36,49 @@ function ConvoOptions({
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { index } = useChatContext();
|
const { index } = useChatContext();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
|
||||||
const { navigateToConvo } = useNavigateToConvo(index);
|
const { navigateToConvo } = useNavigateToConvo(index);
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { conversationId: currentConvoId } = useParams();
|
||||||
|
const { newConversation } = useNewConvo();
|
||||||
|
|
||||||
const shareButtonRef = useRef<HTMLButtonElement>(null);
|
const shareButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
const archiveConvoMutation = useArchiveConvoMutation();
|
||||||
|
|
||||||
|
const archiveHandler = async () => {
|
||||||
|
const convoId = conversationId ?? '';
|
||||||
|
|
||||||
|
if (!convoId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveConvoMutation.mutate(
|
||||||
|
{ conversationId: convoId, isArchived: true },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||||
|
newConversation();
|
||||||
|
navigate('/c/new', { replace: true });
|
||||||
|
}
|
||||||
|
retainView();
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_archive_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
showIcon: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const duplicateConversation = useDuplicateConversationMutation({
|
const duplicateConversation = useDuplicateConversationMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
navigateToConvo(data.conversation);
|
navigateToConvo(data.conversation);
|
||||||
|
|
@ -46,6 +86,7 @@ function ConvoOptions({
|
||||||
message: localize('com_ui_duplication_success'),
|
message: localize('com_ui_duplication_success'),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
|
setIsPopoverActive(false);
|
||||||
},
|
},
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -61,56 +102,118 @@ function ConvoOptions({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shareHandler = () => {
|
const isDuplicateLoading = duplicateConversation.isLoading;
|
||||||
|
const isArchiveLoading = archiveConvoMutation.isLoading;
|
||||||
|
|
||||||
|
const handleShareClick = useCallback(() => {
|
||||||
setShowShareDialog(true);
|
setShowShareDialog(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const deleteHandler = () => {
|
const handleDeleteClick = useCallback(() => {
|
||||||
setShowDeleteDialog(true);
|
setShowDeleteDialog(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const duplicateHandler = () => {
|
const handleArchiveClick = useCallback(async () => {
|
||||||
setIsPopoverActive(false);
|
const convoId = conversationId ?? '';
|
||||||
|
if (!convoId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveConvoMutation.mutate(
|
||||||
|
{ conversationId: convoId, isArchived: true },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||||
|
newConversation();
|
||||||
|
navigate('/c/new', { replace: true });
|
||||||
|
}
|
||||||
|
retainView();
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_archive_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
showIcon: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
conversationId,
|
||||||
|
currentConvoId,
|
||||||
|
archiveConvoMutation,
|
||||||
|
navigate,
|
||||||
|
newConversation,
|
||||||
|
retainView,
|
||||||
|
setIsPopoverActive,
|
||||||
|
showToast,
|
||||||
|
localize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicateClick = useCallback(() => {
|
||||||
duplicateConversation.mutate({
|
duplicateConversation.mutate({
|
||||||
conversationId: conversationId ?? '',
|
conversationId: conversationId ?? '',
|
||||||
});
|
});
|
||||||
};
|
}, [conversationId, duplicateConversation]);
|
||||||
|
|
||||||
const dropdownItems: t.MenuItemProps[] = [
|
const dropdownItems = useMemo(
|
||||||
{
|
() => [
|
||||||
label: localize('com_ui_share'),
|
{
|
||||||
onClick: shareHandler,
|
label: localize('com_ui_share'),
|
||||||
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
onClick: handleShareClick,
|
||||||
show: startupConfig && startupConfig.sharedLinksEnabled,
|
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
||||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
show: startupConfig && startupConfig.sharedLinksEnabled,
|
||||||
hideOnClick: false,
|
hideOnClick: false,
|
||||||
ref: shareButtonRef,
|
ref: shareButtonRef,
|
||||||
render: (props) => <button {...props} />,
|
render: (props) => <button {...props} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: localize('com_ui_rename'),
|
label: localize('com_ui_rename'),
|
||||||
onClick: renameHandler,
|
onClick: renameHandler,
|
||||||
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: localize('com_ui_duplicate'),
|
label: localize('com_ui_duplicate'),
|
||||||
onClick: duplicateHandler,
|
onClick: handleDuplicateClick,
|
||||||
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
|
hideOnClick: false,
|
||||||
},
|
icon: isDuplicateLoading ? (
|
||||||
{
|
<Spinner className="size-4" />
|
||||||
label: localize('com_ui_archive'),
|
) : (
|
||||||
onClick: archiveHandler,
|
<Copy className="icon-sm mr-2 text-text-primary" />
|
||||||
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: localize('com_ui_delete'),
|
label: localize('com_ui_archive'),
|
||||||
onClick: deleteHandler,
|
onClick: handleArchiveClick,
|
||||||
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
hideOnClick: false,
|
||||||
hideOnClick: false,
|
icon: isArchiveLoading ? (
|
||||||
ref: deleteButtonRef,
|
<Spinner className="size-4" />
|
||||||
render: (props) => <button {...props} />,
|
) : (
|
||||||
},
|
<Archive className="icon-sm mr-2 text-text-primary" />
|
||||||
];
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: localize('com_ui_delete'),
|
||||||
|
onClick: handleDeleteClick,
|
||||||
|
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||||
|
hideOnClick: false,
|
||||||
|
ref: deleteButtonRef,
|
||||||
|
render: (props) => <button {...props} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
localize,
|
||||||
|
handleShareClick,
|
||||||
|
startupConfig,
|
||||||
|
renameHandler,
|
||||||
|
handleDuplicateClick,
|
||||||
|
isDuplicateLoading,
|
||||||
|
handleArchiveClick,
|
||||||
|
isArchiveLoading,
|
||||||
|
handleDeleteClick,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const menuId = useId();
|
const menuId = useId();
|
||||||
|
|
||||||
|
|
@ -129,6 +232,7 @@ function ConvoOptions({
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||||
</Menu.MenuButton>
|
</Menu.MenuButton>
|
||||||
|
|
@ -158,4 +262,11 @@ function ConvoOptions({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ConvoOptions);
|
export default memo(ConvoOptions, (prevProps, nextProps) => {
|
||||||
|
return (
|
||||||
|
prevProps.conversationId === nextProps.conversationId &&
|
||||||
|
prevProps.title === nextProps.title &&
|
||||||
|
prevProps.isPopoverActive === nextProps.isPopoverActive &&
|
||||||
|
prevProps.isActiveConvo === nextProps.isActiveConvo
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { QueryKeys } from 'librechat-data-provider';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
|
import {
|
||||||
|
Label,
|
||||||
|
OGDialog,
|
||||||
|
OGDialogTitle,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogHeader,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
} from '~/components';
|
||||||
import { useDeleteConversationMutation } from '~/data-provider';
|
import { useDeleteConversationMutation } from '~/data-provider';
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
|
||||||
import { useLocalize, useNewConvo } from '~/hooks';
|
import { useLocalize, useNewConvo } from '~/hooks';
|
||||||
import { OGDialog, Label } from '~/components';
|
import { NotificationSeverity } from '~/common';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
|
||||||
type DeleteButtonProps = {
|
type DeleteButtonProps = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|
@ -18,10 +27,12 @@ type DeleteButtonProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeleteConversationDialog({
|
export function DeleteConversationDialog({
|
||||||
|
setShowDeleteDialog,
|
||||||
conversationId,
|
conversationId,
|
||||||
retainView,
|
retainView,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
|
setShowDeleteDialog: (value: boolean) => void;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
retainView: () => void;
|
retainView: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -29,17 +40,26 @@ export function DeleteConversationDialog({
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const { newConversation } = useNewConvo();
|
const { newConversation } = useNewConvo();
|
||||||
const { conversationId: currentConvoId } = useParams();
|
const { conversationId: currentConvoId } = useParams();
|
||||||
|
|
||||||
const deleteConvoMutation = useDeleteConversationMutation({
|
const deleteMutation = useDeleteConversationMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
if (currentConvoId === conversationId || currentConvoId === 'new') {
|
if (currentConvoId === conversationId || currentConvoId === 'new') {
|
||||||
newConversation();
|
newConversation();
|
||||||
navigate('/c/new', { replace: true });
|
navigate('/c/new', { replace: true });
|
||||||
}
|
}
|
||||||
retainView();
|
retainView();
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_convo_delete_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
showIcon: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmDelete = useCallback(() => {
|
const confirmDelete = useCallback(() => {
|
||||||
|
|
@ -47,32 +67,29 @@ export function DeleteConversationDialog({
|
||||||
const thread_id = messages?.[messages.length - 1]?.thread_id;
|
const thread_id = messages?.[messages.length - 1]?.thread_id;
|
||||||
const endpoint = messages?.[messages.length - 1]?.endpoint;
|
const endpoint = messages?.[messages.length - 1]?.endpoint;
|
||||||
|
|
||||||
deleteConvoMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
|
deleteMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
|
||||||
}, [conversationId, deleteConvoMutation, queryClient]);
|
}, [conversationId, deleteMutation, queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialogTemplate
|
<OGDialogContent
|
||||||
showCloseButton={false}
|
title={localize('com_ui_delete_confirm') + ' ' + title}
|
||||||
title={localize('com_ui_delete_conversation')}
|
className="w-11/12 max-w-md"
|
||||||
className="max-w-[450px]"
|
>
|
||||||
main={
|
<OGDialogHeader>
|
||||||
<>
|
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
</OGDialogHeader>
|
||||||
<div className="grid w-full items-center gap-2">
|
<div>
|
||||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
||||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
|
</div>
|
||||||
</Label>
|
<div className="flex justify-end gap-4 pt-4">
|
||||||
</div>
|
<Button aria-label="cancel" variant="outline" onClick={() => setShowDeleteDialog(false)}>
|
||||||
</div>
|
{localize('com_ui_cancel')}
|
||||||
</>
|
</Button>
|
||||||
}
|
<Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isLoading}>
|
||||||
selection={{
|
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
||||||
selectHandler: confirmDelete,
|
</Button>
|
||||||
selectClasses:
|
</div>
|
||||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
</OGDialogContent>
|
||||||
selectText: localize('com_ui_delete'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +101,7 @@ export default function DeleteButton({
|
||||||
setShowDeleteDialog,
|
setShowDeleteDialog,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
}: DeleteButtonProps) {
|
}: DeleteButtonProps) {
|
||||||
if (showDeleteDialog === undefined && setShowDeleteDialog === undefined) {
|
if (showDeleteDialog === undefined || setShowDeleteDialog === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,8 +110,9 @@ export default function DeleteButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={triggerRef}>
|
<OGDialog open={showDeleteDialog!} onOpenChange={setShowDeleteDialog!} triggerRef={triggerRef}>
|
||||||
<DeleteConversationDialog
|
<DeleteConversationDialog
|
||||||
|
setShowDeleteDialog={setShowDeleteDialog}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
retainView={retainView}
|
retainView={retainView}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './DeleteButton';
|
||||||
|
export { default as ShareButton } from './ShareButton';
|
||||||
|
export { default as SharedLinkButton } from './SharedLinkButton';
|
||||||
|
export { default as ConvoOptions } from './ConvoOptions';
|
||||||
77
client/src/components/Conversations/RenameForm.tsx
Normal file
77
client/src/components/Conversations/RenameForm.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
interface RenameFormProps {
|
||||||
|
titleInput: string;
|
||||||
|
setTitleInput: (value: string) => void;
|
||||||
|
onSubmit: (title: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
localize: (key: any, options?: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenameForm: React.FC<RenameFormProps> = ({
|
||||||
|
titleInput,
|
||||||
|
setTitleInput,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
localize,
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
onCancel();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
onSubmit(titleInput);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5"
|
||||||
|
role="form"
|
||||||
|
aria-label={localize('com_ui_rename_conversation')}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
|
||||||
|
value={titleInput}
|
||||||
|
onChange={(e) => setTitleInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => onSubmit(titleInput)}
|
||||||
|
maxLength={100}
|
||||||
|
aria-label={localize('com_ui_new_conversation_title')}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1" role="toolbar">
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel()}
|
||||||
|
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||||
|
aria-label={localize('com_ui_cancel')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(titleInput)}
|
||||||
|
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||||
|
aria-label={localize('com_ui_save')}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RenameForm;
|
||||||
|
|
@ -30,7 +30,7 @@ function AccountSettings() {
|
||||||
<Select.Select
|
<Select.Select
|
||||||
aria-label={localize('com_nav_account_settings')}
|
aria-label={localize('com_nav_account_settings')}
|
||||||
data-testid="nav-user"
|
data-testid="nav-user"
|
||||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-accent"
|
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
||||||
data-testid="bookmark-menu"
|
data-testid="bookmark-menu"
|
||||||
>
|
>
|
||||||
<div className="h-7 w-7 flex-shrink-0">
|
<div className="h-7 w-7 flex-shrink-0">
|
||||||
<div className="relative flex h-full items-center justify-center rounded-full border border-border-medium bg-surface-primary-alt text-text-primary">
|
<div className="relative flex h-full items-center justify-center text-text-primary">
|
||||||
{tags.length > 0 ? (
|
{tags.length > 0 ? (
|
||||||
<BookmarkFilledIcon className="h-4 w-4" aria-hidden="true" />
|
<BookmarkFilledIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -45,7 +45,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
||||||
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
|
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
|
||||||
</div>
|
</div>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-active-alt p-1.5 shadow-lg outline-none">
|
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none">
|
||||||
{data && conversation && (
|
{data && conversation && (
|
||||||
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
||||||
<BookmarkNavItems
|
<BookmarkNavItems
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import 'test/resizeObserver.mock';
|
|
||||||
import 'test/matchMedia.mock';
|
|
||||||
import 'test/localStorage.mock';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import { RecoilRoot } from 'recoil';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
|
||||||
|
|
||||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
|
||||||
import { SearchContext } from '~/Providers';
|
|
||||||
import Nav from './Nav';
|
|
||||||
|
|
||||||
const renderNav = ({ search, navVisible, setNavVisible }) => {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<RecoilRoot>
|
|
||||||
<BrowserRouter>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<AuthContextProvider>
|
|
||||||
<SearchContext.Provider value={search}>
|
|
||||||
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
|
|
||||||
</SearchContext.Provider>
|
|
||||||
</AuthContextProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</RecoilRoot>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMatchMedia = (mediaQueryList?: string[]) => {
|
|
||||||
mediaQueryList = mediaQueryList || [];
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
value: jest.fn().mockImplementation((query) => ({
|
|
||||||
matches: mediaQueryList.includes(query),
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: jest.fn(),
|
|
||||||
removeEventListener: jest.fn(),
|
|
||||||
dispatchEvent: jest.fn(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Nav', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockMatchMedia();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders visible', () => {
|
|
||||||
const { getByTestId } = renderNav({
|
|
||||||
search: { data: [], pageNumber: 1 },
|
|
||||||
navVisible: true,
|
|
||||||
setNavVisible: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByTestId('nav')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders hidden', async () => {
|
|
||||||
const { getByTestId } = renderNav({
|
|
||||||
search: { data: [], pageNumber: 1 },
|
|
||||||
navVisible: false,
|
|
||||||
setNavVisible: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByTestId('nav')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders hidden when small screen is detected', async () => {
|
|
||||||
mockMatchMedia(['(max-width: 768px)']);
|
|
||||||
|
|
||||||
const navVisible = true;
|
|
||||||
const mockSetNavVisible = jest.fn();
|
|
||||||
|
|
||||||
const { getByTestId } = renderNav({
|
|
||||||
search: { data: [], pageNumber: 1 },
|
|
||||||
navVisible: navVisible,
|
|
||||||
setNavVisible: mockSetNavVisible,
|
|
||||||
});
|
|
||||||
|
|
||||||
// nav is initially visible
|
|
||||||
expect(getByTestId('nav')).toBeVisible();
|
|
||||||
|
|
||||||
// when small screen is detected, the nav is hidden
|
|
||||||
expect(mockSetNavVisible.mock.calls).toHaveLength(1);
|
|
||||||
const updatedNavVisible = mockSetNavVisible.mock.calls[0][0](navVisible);
|
|
||||||
expect(updatedNavVisible).not.toEqual(navVisible);
|
|
||||||
expect(updatedNavVisible).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
|
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
import type {
|
||||||
|
TConversation,
|
||||||
|
ConversationListResponse,
|
||||||
|
SearchConversationListResponse,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
|
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
useLocalize,
|
useLocalize,
|
||||||
useHasAccess,
|
useHasAccess,
|
||||||
|
|
@ -12,213 +17,260 @@ import {
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||||
import { Conversations } from '~/components/Conversations';
|
import { Conversations } from '~/components/Conversations';
|
||||||
import BookmarkNav from './Bookmarks/BookmarkNav';
|
|
||||||
import AccountSettings from './AccountSettings';
|
|
||||||
import { useSearchContext } from '~/Providers';
|
import { useSearchContext } from '~/Providers';
|
||||||
import { Spinner } from '~/components/svg';
|
import { Spinner } from '~/components';
|
||||||
import SearchBar from './SearchBar';
|
|
||||||
import NavToggle from './NavToggle';
|
import NavToggle from './NavToggle';
|
||||||
|
import SearchBar from './SearchBar';
|
||||||
import NewChat from './NewChat';
|
import NewChat from './NewChat';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const Nav = ({
|
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||||
navVisible,
|
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||||
setNavVisible,
|
|
||||||
}: {
|
|
||||||
navVisible: boolean;
|
|
||||||
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}) => {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { isAuthenticated } = useAuthContext();
|
|
||||||
|
|
||||||
const [navWidth, setNavWidth] = useState('260px');
|
const NAV_WIDTH_DESKTOP = '260px';
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const NAV_WIDTH_MOBILE = '320px';
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
||||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
|
||||||
const [isToggleHovering, setIsToggleHovering] = useState(false);
|
|
||||||
|
|
||||||
const hasAccessToBookmarks = useHasAccess({
|
const NavMask = memo(
|
||||||
permissionType: PermissionTypes.BOOKMARKS,
|
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
|
||||||
permission: Permissions.USE,
|
<div
|
||||||
});
|
id="mobile-nav-mask-toggle"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`nav-mask ${navVisible ? 'active' : ''}`}
|
||||||
|
onClick={toggleNavVisible}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
toggleNavVisible();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const MemoNewChat = memo(NewChat);
|
||||||
setIsHovering(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const Nav = memo(
|
||||||
setIsHovering(false);
|
({
|
||||||
}, []);
|
navVisible,
|
||||||
|
setNavVisible,
|
||||||
|
}: {
|
||||||
|
navVisible: boolean;
|
||||||
|
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { isAuthenticated } = useAuthContext();
|
||||||
|
|
||||||
useEffect(() => {
|
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
||||||
if (isSmallScreen) {
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const savedNavVisible = localStorage.getItem('navVisible');
|
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||||
if (savedNavVisible === null) {
|
const [isToggleHovering, setIsToggleHovering] = useState(false);
|
||||||
toggleNavVisible();
|
const [showLoading, setShowLoading] = useState(false);
|
||||||
}
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
setNavWidth('320px');
|
|
||||||
} else {
|
|
||||||
setNavWidth('260px');
|
|
||||||
}
|
|
||||||
}, [isSmallScreen]);
|
|
||||||
|
|
||||||
const [showLoading, setShowLoading] = useState(false);
|
const hasAccessToBookmarks = useHasAccess({
|
||||||
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
|
permissionType: PermissionTypes.BOOKMARKS,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
const { pageNumber, searchQuery, setPageNumber, searchQueryRes } = useSearchContext();
|
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const isSearchTyping = useRecoilValue(store.isSearchTyping);
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
const { searchQuery, searchQueryRes } = useSearchContext();
|
||||||
useConversationsInfiniteQuery(
|
|
||||||
|
const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery(
|
||||||
{
|
{
|
||||||
pageNumber: pageNumber.toString(),
|
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
tags: tags.length === 0 ? undefined : tags,
|
tags: tags.length === 0 ? undefined : tags,
|
||||||
},
|
},
|
||||||
{ enabled: isAuthenticated },
|
{
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 30000,
|
||||||
|
cacheTime: 300000,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
|
||||||
// When a tag is selected, refetch the list of conversations related to that tag
|
|
||||||
refetch();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [tags]);
|
|
||||||
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
|
|
||||||
setShowLoading,
|
|
||||||
hasNextPage: searchQuery ? searchQueryRes?.hasNextPage : hasNextPage,
|
|
||||||
fetchNextPage: searchQuery ? searchQueryRes?.fetchNextPage : fetchNextPage,
|
|
||||||
isFetchingNextPage: searchQuery
|
|
||||||
? searchQueryRes?.isFetchingNextPage ?? false
|
|
||||||
: isFetchingNextPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversations = useMemo(
|
const computedHasNextPage = useMemo(() => {
|
||||||
() =>
|
if (searchQuery && searchQueryRes?.data) {
|
||||||
(searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) ||
|
const pages = searchQueryRes.data.pages;
|
||||||
[],
|
return pages[pages.length - 1]?.nextCursor !== null;
|
||||||
[data, searchQuery, searchQueryRes?.data],
|
} else 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]);
|
||||||
|
|
||||||
const toggleNavVisible = () => {
|
const outerContainerRef = useRef<HTMLDivElement>(null);
|
||||||
setNavVisible((prev: boolean) => {
|
const listRef = useRef<any>(null);
|
||||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
|
||||||
return !prev;
|
|
||||||
});
|
|
||||||
if (newUser) {
|
|
||||||
setNewUser(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemToggleNav = () => {
|
const { moveToTop } = useNavScrolling<
|
||||||
if (isSmallScreen) {
|
ConversationListResponse | SearchConversationListResponse
|
||||||
toggleNavVisible();
|
>({
|
||||||
}
|
setShowLoading,
|
||||||
};
|
fetchNextPage: async (options?) => {
|
||||||
|
if (computedHasNextPage) {
|
||||||
return (
|
if (searchQuery && searchQueryRes) {
|
||||||
<>
|
const pages = searchQueryRes.data?.pages;
|
||||||
<div
|
if (pages && pages.length > 0 && pages[pages.length - 1]?.nextCursor !== null) {
|
||||||
data-testid="nav"
|
return searchQueryRes.fetchNextPage(options);
|
||||||
className={
|
}
|
||||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt md:max-w-[260px]'
|
} else {
|
||||||
|
return fetchNextPage(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
style={{
|
return Promise.resolve(
|
||||||
width: navVisible ? navWidth : '0px',
|
{} as InfiniteQueryObserverResult<
|
||||||
visibility: navVisible ? 'visible' : 'hidden',
|
SearchConversationListResponse | ConversationListResponse,
|
||||||
transition: 'width 0.2s, visibility 0.2s',
|
unknown
|
||||||
}}
|
>,
|
||||||
>
|
);
|
||||||
<div className="h-full w-[320px] md:w-[260px]">
|
},
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
isFetchingNext: searchQuery
|
||||||
<div
|
? (searchQueryRes?.isFetchingNextPage ?? false)
|
||||||
className={cn(
|
: isFetchingNextPage,
|
||||||
'flex h-full min-h-0 flex-col transition-opacity',
|
});
|
||||||
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
|
|
||||||
)}
|
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]);
|
||||||
|
|
||||||
|
const toggleNavVisible = useCallback(() => {
|
||||||
|
setNavVisible((prev: boolean) => {
|
||||||
|
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
if (newUser) {
|
||||||
|
setNewUser(false);
|
||||||
|
}
|
||||||
|
}, [newUser, setNavVisible, setNewUser]);
|
||||||
|
|
||||||
|
const itemToggleNav = useCallback(() => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
toggleNavVisible();
|
||||||
|
}
|
||||||
|
}, [isSmallScreen, toggleNavVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
const savedNavVisible = localStorage.getItem('navVisible');
|
||||||
|
if (savedNavVisible === null) {
|
||||||
|
toggleNavVisible();
|
||||||
|
}
|
||||||
|
setNavWidth(NAV_WIDTH_MOBILE);
|
||||||
|
} else {
|
||||||
|
setNavWidth(NAV_WIDTH_DESKTOP);
|
||||||
|
}
|
||||||
|
}, [isSmallScreen, toggleNavVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [tags, refetch]);
|
||||||
|
|
||||||
|
const loadMoreConversations = useCallback(() => {
|
||||||
|
if (isFetchingNextPage || !computedHasNextPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextPage();
|
||||||
|
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const subHeaders = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{isSearchEnabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
|
||||||
|
{hasAccessToBookmarks && (
|
||||||
|
<>
|
||||||
|
<div className="mt-1.5" />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearchLoading =
|
||||||
|
!!searchQuery &&
|
||||||
|
(isSearchTyping ||
|
||||||
|
(searchQueryRes?.isLoading ?? false) ||
|
||||||
|
(searchQueryRes?.isFetching ?? false));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="nav"
|
||||||
|
className={cn(
|
||||||
|
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
|
||||||
|
'md:max-w-[260px]',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: navVisible ? navWidth : '0px',
|
||||||
|
visibility: navVisible ? 'visible' : 'hidden',
|
||||||
|
transition: 'width 0.2s, visibility 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full w-[320px] md:w-[260px]">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20',
|
'flex h-full flex-col transition-opacity',
|
||||||
|
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<nav
|
<div className="flex h-full flex-col">
|
||||||
id="chat-history-nav"
|
<nav
|
||||||
aria-label={localize('com_ui_chat_history')}
|
id="chat-history-nav"
|
||||||
className="flex h-full w-full flex-col px-3 pb-3.5"
|
aria-label={localize('com_ui_chat_history')}
|
||||||
>
|
className="flex h-full flex-col px-3 pb-3.5"
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
|
||||||
isHovering ? '' : 'scrollbar-transparent',
|
|
||||||
)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
>
|
||||||
<NewChat
|
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
|
||||||
toggleNav={itemToggleNav}
|
<MemoNewChat
|
||||||
isSmallScreen={isSmallScreen}
|
toggleNav={itemToggleNav}
|
||||||
subHeaders={
|
isSmallScreen={isSmallScreen}
|
||||||
<>
|
subHeaders={subHeaders}
|
||||||
{isSearchEnabled === true && (
|
/>
|
||||||
<SearchBar
|
<Conversations
|
||||||
setPageNumber={setPageNumber}
|
conversations={conversations}
|
||||||
isSmallScreen={isSmallScreen}
|
moveToTop={moveToTop}
|
||||||
/>
|
toggleNav={itemToggleNav}
|
||||||
)}
|
containerRef={listRef}
|
||||||
{hasAccessToBookmarks === true && (
|
loadMoreConversations={loadMoreConversations}
|
||||||
<>
|
isFetchingNextPage={isFetchingNextPage || showLoading}
|
||||||
<div className="mt-1.5" />
|
isSearchLoading={isSearchLoading}
|
||||||
<BookmarkNav
|
/>
|
||||||
tags={tags}
|
</div>
|
||||||
setTags={setTags}
|
<Suspense fallback={null}>
|
||||||
isSmallScreen={isSmallScreen}
|
<AccountSettings />
|
||||||
/>
|
</Suspense>
|
||||||
</>
|
</nav>
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Conversations
|
|
||||||
conversations={conversations}
|
|
||||||
moveToTop={moveToTop}
|
|
||||||
toggleNav={itemToggleNav}
|
|
||||||
/>
|
|
||||||
{(isFetchingNextPage || showLoading) && (
|
|
||||||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-text-primary')} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AccountSettings />
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<NavToggle
|
|
||||||
isHovering={isToggleHovering}
|
|
||||||
setIsHovering={setIsToggleHovering}
|
|
||||||
onToggle={toggleNavVisible}
|
|
||||||
navVisible={navVisible}
|
|
||||||
className="fixed left-0 top-1/2 z-40 hidden md:flex"
|
|
||||||
/>
|
|
||||||
{isSmallScreen && (
|
|
||||||
<div
|
|
||||||
id="mobile-nav-mask-toggle"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={`nav-mask ${navVisible ? 'active' : ''}`}
|
|
||||||
onClick={toggleNavVisible}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
toggleNavVisible();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Toggle navigation"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(Nav);
|
<NavToggle
|
||||||
|
isHovering={isToggleHovering}
|
||||||
|
setIsHovering={setIsToggleHovering}
|
||||||
|
onToggle={toggleNavVisible}
|
||||||
|
navVisible={navVisible}
|
||||||
|
className="fixed left-0 top-1/2 z-40 hidden md:flex"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Nav.displayName = 'Nav';
|
||||||
|
|
||||||
|
export default Nav;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -13,10 +14,24 @@ import { NewChatIcon } from '~/components/svg';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => {
|
const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConversation | null }) => {
|
||||||
const searchQuery = useRecoilValue(store.searchQuery);
|
const searchQuery = useRecoilValue(store.searchQuery);
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
|
||||||
|
const computedIcon = useMemo(() => {
|
||||||
|
if (searchQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let { endpoint = '' } = conversation ?? {};
|
||||||
|
const iconURL = conversation?.iconURL ?? '';
|
||||||
|
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||||
|
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||||
|
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||||
|
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
|
||||||
|
const Icon = icons[iconKey];
|
||||||
|
return { iconURL, endpoint, endpointType, endpointIconURL, Icon };
|
||||||
|
}, [searchQuery, conversation, endpointsConfig]);
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
return (
|
return (
|
||||||
<div className="shadow-stroke relative flex h-7 w-7 items-center justify-center rounded-full bg-white text-black dark:bg-white">
|
<div className="shadow-stroke relative flex h-7 w-7 items-center justify-center rounded-full bg-white text-black dark:bg-white">
|
||||||
|
|
@ -25,14 +40,11 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let { endpoint = '' } = conversation ?? {};
|
if (!computedIcon) {
|
||||||
const iconURL = conversation?.iconURL ?? '';
|
return null;
|
||||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
}
|
||||||
|
|
||||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
const { iconURL, endpoint, endpointIconURL, Icon } = computedIcon;
|
||||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
|
||||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
|
|
||||||
const Icon = icons[iconKey];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-7 w-7 flex-shrink-0">
|
<div className="h-7 w-7 flex-shrink-0">
|
||||||
|
|
@ -45,13 +57,12 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||||
{endpoint && Icon != null && (
|
{endpoint && Icon && (
|
||||||
<Icon
|
<Icon
|
||||||
size={41}
|
size={41}
|
||||||
context="nav"
|
context="nav"
|
||||||
className="h-2/3 w-2/3"
|
className="h-2/3 w-2/3"
|
||||||
endpoint={endpoint}
|
endpoint={endpoint}
|
||||||
endpointType={endpointType}
|
|
||||||
iconURL={endpointIconURL}
|
iconURL={endpointIconURL}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -59,7 +70,7 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default function NewChat({
|
export default function NewChat({
|
||||||
index = 0,
|
index = 0,
|
||||||
|
|
@ -77,21 +88,23 @@ export default function NewChat({
|
||||||
const { newConversation: newConvo } = useNewConvo(index);
|
const { newConversation: newConvo } = useNewConvo(index);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const { conversation } = store.useCreateConversationAtom(index);
|
const { conversation } = store.useCreateConversationAtom(index);
|
||||||
|
|
||||||
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
const clickHandler = useCallback(
|
||||||
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
event.preventDefault();
|
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
||||||
queryClient.setQueryData<TMessage[]>(
|
event.preventDefault();
|
||||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
queryClient.setQueryData<TMessage[]>(
|
||||||
[],
|
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||||
);
|
[],
|
||||||
newConvo();
|
);
|
||||||
navigate('/c/new');
|
newConvo();
|
||||||
toggleNav();
|
navigate('/c/new');
|
||||||
}
|
toggleNav();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[queryClient, conversation, newConvo, navigate, toggleNav],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
|
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,13 @@ import store from '~/store';
|
||||||
|
|
||||||
type SearchBarProps = {
|
type SearchBarProps = {
|
||||||
isSmallScreen?: boolean;
|
isSmallScreen?: boolean;
|
||||||
setPageNumber: React.Dispatch<React.SetStateAction<number>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
|
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setPageNumber, isSmallScreen } = props;
|
const { isSmallScreen } = props;
|
||||||
|
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [showClearIcon, setShowClearIcon] = useState(false);
|
const [showClearIcon, setShowClearIcon] = useState(false);
|
||||||
|
|
@ -27,13 +26,13 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
||||||
const clearConvoState = store.useClearConvoState();
|
const clearConvoState = store.useClearConvoState();
|
||||||
const setSearchQuery = useSetRecoilState(store.searchQuery);
|
const setSearchQuery = useSetRecoilState(store.searchQuery);
|
||||||
const setIsSearching = useSetRecoilState(store.isSearching);
|
const setIsSearching = useSetRecoilState(store.isSearching);
|
||||||
|
const setIsSearchTyping = useSetRecoilState(store.isSearchTyping);
|
||||||
|
|
||||||
const clearSearch = useCallback(() => {
|
const clearSearch = useCallback(() => {
|
||||||
setPageNumber(1);
|
|
||||||
if (location.pathname.includes('/search')) {
|
if (location.pathname.includes('/search')) {
|
||||||
newConversation({ disableFocus: true });
|
newConversation({ disableFocus: true });
|
||||||
}
|
}
|
||||||
}, [newConversation, setPageNumber, location.pathname]);
|
}, [newConversation, location.pathname]);
|
||||||
|
|
||||||
const clearText = useCallback(() => {
|
const clearText = useCallback(() => {
|
||||||
setShowClearIcon(false);
|
setShowClearIcon(false);
|
||||||
|
|
@ -61,15 +60,22 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
||||||
[queryClient, clearConvoState, setSearchQuery],
|
[queryClient, clearConvoState, setSearchQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: make the debounce time configurable via yaml
|
const debouncedSendRequest = useMemo(
|
||||||
const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]);
|
() =>
|
||||||
|
debounce((value: string) => {
|
||||||
|
sendRequest(value);
|
||||||
|
}, 350),
|
||||||
|
[sendRequest, setIsSearchTyping],
|
||||||
|
);
|
||||||
|
|
||||||
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
|
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { value } = e.target as HTMLInputElement;
|
const value = e.target.value;
|
||||||
setShowClearIcon(value.length > 0);
|
setShowClearIcon(value.length > 0);
|
||||||
setText(value);
|
setText(value);
|
||||||
|
setSearchQuery(value);
|
||||||
|
setIsSearchTyping(true);
|
||||||
|
// debounce only the API call
|
||||||
debouncedSendRequest(value);
|
debouncedSendRequest(value);
|
||||||
setIsSearching(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,9 +86,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
||||||
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{
|
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
|
||||||
}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsTabs: { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[] = [
|
const settingsTabs: {
|
||||||
|
value: SettingsTabValues;
|
||||||
|
icon: React.JSX.Element;
|
||||||
|
label: TranslationKeys;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
value: SettingsTabValues.GENERAL,
|
value: SettingsTabValues.GENERAL,
|
||||||
icon: <GearIcon />,
|
icon: <GearIcon />,
|
||||||
|
|
@ -144,7 +148,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
||||||
|
|
@ -168,10 +172,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
key={value}
|
key={value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative z-10 m-1 flex items-center justify-start gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out',
|
'group relative z-10 m-1 flex items-center justify-start gap-2 rounded-xl px-2 py-1.5 transition-all duration-200 ease-in-out',
|
||||||
isSmallScreen
|
isSmallScreen
|
||||||
? 'flex-1 justify-center text-nowrap rounded-xl p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
? 'flex-1 justify-center text-nowrap p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
||||||
: 'rounded-md bg-transparent text-text-primary radix-state-active:bg-surface-tertiary',
|
: 'bg-transparent text-text-secondary radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
|
||||||
)}
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
ref={(el) => (tabRefs.current[value] = el)}
|
ref={(el) => (tabRefs.current[value] = el)}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export default function FontSizeSelector() {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
testId="font-size-selector"
|
testId="font-size-selector"
|
||||||
sizeClasses="w-[150px]"
|
sizeClasses="w-[150px]"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
|
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
OGDialog,
|
OGDialog,
|
||||||
|
|
@ -9,10 +9,11 @@ import {
|
||||||
OGDialogContent,
|
OGDialogContent,
|
||||||
OGDialogHeader,
|
OGDialogHeader,
|
||||||
OGDialogTitle,
|
OGDialogTitle,
|
||||||
Button,
|
|
||||||
TooltipAnchor,
|
TooltipAnchor,
|
||||||
|
Button,
|
||||||
Label,
|
Label,
|
||||||
} from '~/components/ui';
|
Spinner,
|
||||||
|
} from '~/components';
|
||||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||||
|
|
@ -20,7 +21,6 @@ import DataTable from '~/components/ui/DataTable';
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import { formatDate } from '~/utils';
|
import { formatDate } from '~/utils';
|
||||||
import { Spinner } from '~/components/svg';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|
@ -37,6 +37,7 @@ export default function SharedLinks() {
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -144,8 +145,6 @@ export default function SharedLinks() {
|
||||||
await fetchNextPage();
|
await fetchNextPage();
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
|
||||||
|
|
||||||
const confirmDelete = useCallback(() => {
|
const confirmDelete = useCallback(() => {
|
||||||
if (deleteRow) {
|
if (deleteRow) {
|
||||||
handleDelete([deleteRow]);
|
handleDelete([deleteRow]);
|
||||||
|
|
@ -157,21 +156,30 @@ export default function SharedLinks() {
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const isSorted = queryParams.sortBy === 'title';
|
||||||
|
const sortDirection = queryParams.sortDirection;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
onClick={() => handleSort('title', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
|
onClick={() =>
|
||||||
|
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{localize('com_ui_name')}
|
{localize('com_ui_name')}
|
||||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
{isSorted && sortDirection === 'asc' && (
|
||||||
|
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{isSorted && sortDirection === 'desc' && (
|
||||||
|
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { title, shareId } = row.original;
|
const { title, shareId } = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -193,17 +201,25 @@ export default function SharedLinks() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const isSorted = queryParams.sortBy === 'createdAt';
|
||||||
|
const sortDirection = queryParams.sortDirection;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSort('createdAt', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
|
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{localize('com_ui_date')}
|
{localize('com_ui_date')}
|
||||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
{isSorted && sortDirection === 'asc' && (
|
||||||
|
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{isSorted && sortDirection === 'desc' && (
|
||||||
|
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -240,7 +256,7 @@ export default function SharedLinks() {
|
||||||
<MessageSquare className="size-4" />
|
<MessageSquare className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
></TooltipAnchor>
|
/>
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={localize('com_ui_delete')}
|
description={localize('com_ui_delete')}
|
||||||
render={
|
render={
|
||||||
|
|
@ -256,12 +272,12 @@ export default function SharedLinks() {
|
||||||
<TrashIcon className="size-4" />
|
<TrashIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
></TooltipAnchor>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isSmallScreen, localize],
|
[isSmallScreen, localize, queryParams, handleSort],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -291,6 +307,7 @@ export default function SharedLinks() {
|
||||||
showCheckboxes={false}
|
showCheckboxes={false}
|
||||||
onFilterChange={debouncedFilterChange}
|
onFilterChange={debouncedFilterChange}
|
||||||
filterValue={queryParams.search}
|
filterValue={queryParams.search}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</OGDialogContent>
|
</OGDialogContent>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { useLocalize } from '~/hooks';
|
import { useState } from 'react';
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
|
||||||
import { OGDialog, OGDialogTrigger, Button } from '~/components';
|
import { OGDialog, OGDialogTrigger, Button } from '~/components';
|
||||||
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function ArchivedChats() {
|
export default function ArchivedChats() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_archived_chats')}</div>
|
<div>{localize('com_nav_archived_chats')}</div>
|
||||||
<OGDialog>
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button variant="outline" aria-label="Archived chats">
|
<Button variant="outline" aria-label="Archived chats">
|
||||||
{localize('com_ui_manage')}
|
{localize('com_ui_manage')}
|
||||||
|
|
@ -19,7 +21,7 @@ export default function ArchivedChats() {
|
||||||
title={localize('com_nav_archived_chats')}
|
title={localize('com_nav_archived_chats')}
|
||||||
className="max-w-[1000px]"
|
className="max-w-[1000px]"
|
||||||
showCancelButton={false}
|
showCancelButton={false}
|
||||||
main={<ArchivedChatsTable />}
|
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
||||||
/>
|
/>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,287 +1,308 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
||||||
|
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
Search,
|
|
||||||
TrashIcon,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
// ChevronsLeft,
|
|
||||||
// ChevronsRight,
|
|
||||||
MessageCircle,
|
|
||||||
ArchiveRestore,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
TableRow,
|
|
||||||
Skeleton,
|
|
||||||
OGDialog,
|
OGDialog,
|
||||||
Separator,
|
OGDialogContent,
|
||||||
TableCell,
|
OGDialogHeader,
|
||||||
TableBody,
|
OGDialogTitle,
|
||||||
TableHead,
|
Label,
|
||||||
TableHeader,
|
|
||||||
TooltipAnchor,
|
TooltipAnchor,
|
||||||
OGDialogTrigger,
|
Spinner,
|
||||||
} from '~/components';
|
} from '~/components';
|
||||||
import { useConversationsInfiniteQuery, useArchiveConvoMutation } from '~/data-provider';
|
import {
|
||||||
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
|
useArchiveConvoMutation,
|
||||||
import { useAuthContext, useLocalize, useMediaQuery } from '~/hooks';
|
useConversationsInfiniteQuery,
|
||||||
import { cn } from '~/utils';
|
useDeleteConversationMutation,
|
||||||
|
} from '~/data-provider';
|
||||||
|
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||||
|
import { MinimalIcon } from '~/components/Endpoints';
|
||||||
|
import DataTable from '~/components/ui/DataTable';
|
||||||
|
import { NotificationSeverity } from '~/common';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import { formatDate } from '~/utils';
|
||||||
|
|
||||||
export default function ArchivedChatsTable() {
|
const DEFAULT_PARAMS: ConversationListParams = {
|
||||||
|
isArchived: true,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
search: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ArchivedChatsTable({
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { isAuthenticated } = useAuthContext();
|
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const { showToast } = useToastContext();
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
useConversationsInfiniteQuery(
|
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||||
{ pageNumber: currentPage.toString(), isArchived: true },
|
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
|
||||||
{ enabled: isAuthenticated && isOpened },
|
|
||||||
);
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||||
const mutation = useArchiveConvoMutation();
|
useConversationsInfiniteQuery(queryParams, {
|
||||||
const handleUnarchive = useCallback(
|
staleTime: 0,
|
||||||
(conversationId: string) => {
|
cacheTime: 5 * 60 * 1000,
|
||||||
mutation.mutate({ conversationId, isArchived: false });
|
refetchOnWindowFocus: false,
|
||||||
},
|
refetchOnMount: false,
|
||||||
[mutation],
|
});
|
||||||
|
|
||||||
|
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||||
|
setQueryParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sortBy: sortField as 'title' | 'createdAt',
|
||||||
|
sortDirection: sortOrder,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((value: string) => {
|
||||||
|
const encodedValue = encodeURIComponent(value.trim());
|
||||||
|
setQueryParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
search: encodedValue,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debouncedFilterChange = useMemo(
|
||||||
|
() => debounce(handleFilterChange, 300),
|
||||||
|
[handleFilterChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const conversations = useMemo(
|
useEffect(() => {
|
||||||
() => data?.pages[currentPage - 1]?.conversations ?? [],
|
return () => {
|
||||||
[data, currentPage],
|
debouncedFilterChange.cancel();
|
||||||
);
|
};
|
||||||
const totalPages = useMemo(() => Math.ceil(Number(data?.pages[0].pages ?? 1)) ?? 1, [data]);
|
}, [debouncedFilterChange]);
|
||||||
|
|
||||||
const handleChatClick = useCallback((conversationId: string) => {
|
const allConversations = useMemo(() => {
|
||||||
if (!conversationId) {
|
if (!data?.pages) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
window.open(`/c/${conversationId}`, '_blank');
|
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||||
}, []);
|
}, [data?.pages]);
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const deleteMutation = useDeleteConversationMutation({
|
||||||
(newPage: number) => {
|
onSuccess: async () => {
|
||||||
setCurrentPage(newPage);
|
setIsDeleteOpen(false);
|
||||||
if (!(hasNextPage ?? false)) {
|
await refetch();
|
||||||
return;
|
},
|
||||||
}
|
onError: (error: unknown) => {
|
||||||
fetchNextPage({ pageParam: newPage });
|
showToast({
|
||||||
|
message: localize('com_ui_archive_delete_error') as string,
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[fetchNextPage, hasNextPage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearch = useCallback((query: string) => {
|
|
||||||
setSearchQuery(query);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
|
||||||
|
|
||||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
|
||||||
const randomWidth = getRandomWidth();
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex h-10 w-full items-center">
|
|
||||||
<div className="flex w-[410px] items-center">
|
|
||||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow justify-center">
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</div>
|
|
||||||
<div className="mr-2 flex justify-end">
|
|
||||||
<Skeleton className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || isFetchingNextPage) {
|
const unarchiveMutation = useArchiveConvoMutation({
|
||||||
return <div className="text-text-secondary">{skeletons}</div>;
|
onSuccess: async () => {
|
||||||
}
|
await refetch();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_unarchive_error') as string,
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!data || (conversations.length === 0 && totalPages === 0)) {
|
const handleFetchNextPage = useCallback(async () => {
|
||||||
return <div className="text-text-secondary">{localize('com_nav_archived_chats_empty')}</div>;
|
if (!hasNextPage || isFetchingNextPage) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
await fetchNextPage();
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: () => {
|
||||||
|
const isSorted = queryParams.sortBy === 'title';
|
||||||
|
const sortDirection = queryParams.sortDirection;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{localize('com_nav_archive_name')}
|
||||||
|
{isSorted && sortDirection === 'asc' && (
|
||||||
|
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{isSorted && sortDirection === 'desc' && (
|
||||||
|
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { conversationId, title } = row.original;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 truncate"
|
||||||
|
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
|
||||||
|
>
|
||||||
|
<MinimalIcon
|
||||||
|
endpoint={row.original.endpoint}
|
||||||
|
size={28}
|
||||||
|
isCreatedByUser={false}
|
||||||
|
iconClassName="size-4"
|
||||||
|
/>
|
||||||
|
<span className="underline">{title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
size: isSmallScreen ? '70%' : '50%',
|
||||||
|
mobileSize: '70%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: () => {
|
||||||
|
const isSorted = queryParams.sortBy === 'createdAt';
|
||||||
|
const sortDirection = queryParams.sortDirection;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{localize('com_nav_archive_created_at')}
|
||||||
|
{isSorted && sortDirection === 'asc' && (
|
||||||
|
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{isSorted && sortDirection === 'desc' && (
|
||||||
|
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||||
|
meta: {
|
||||||
|
size: isSmallScreen ? '30%' : '35%',
|
||||||
|
mobileSize: '30%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'actions',
|
||||||
|
header: () => (
|
||||||
|
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||||
|
{localize('com_assistants_actions')}
|
||||||
|
</Label>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const conversation = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_unarchive')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
|
onClick={() =>
|
||||||
|
unarchiveMutation.mutate({
|
||||||
|
conversationId: conversation.conversationId,
|
||||||
|
isArchived: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title={localize('com_ui_unarchive')}
|
||||||
|
disabled={unarchiveMutation.isLoading}
|
||||||
|
>
|
||||||
|
{unarchiveMutation.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<ArchiveRestore className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_delete')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteConversation(row.original);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
title={localize('com_ui_delete')}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
size: '15%',
|
||||||
|
mobileSize: '25%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<DataTable
|
||||||
'grid w-full gap-2',
|
columns={columns}
|
||||||
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
data={allConversations}
|
||||||
'max-h-[629px]',
|
filterColumn="title"
|
||||||
)}
|
onFilterChange={debouncedFilterChange}
|
||||||
onMouseEnter={() => setIsOpened(true)}
|
filterValue={queryParams.search}
|
||||||
>
|
fetchNextPage={handleFetchNextPage}
|
||||||
<div className="flex items-center">
|
hasNextPage={hasNextPage}
|
||||||
<Search className="size-4 text-text-secondary" />
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
<Input
|
isLoading={isLoading}
|
||||||
type="text"
|
showCheckboxes={false}
|
||||||
placeholder={localize('com_nav_search_placeholder')}
|
manualSorting={true} // Ensures server-side sorting
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
className="w-full border-none placeholder:text-text-secondary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
{conversations.length === 0 ? (
|
|
||||||
<div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className={cn('p-4', isSmallScreen ? 'w-[70%]' : 'w-[50%]')}>
|
|
||||||
{localize('com_nav_archive_name')}
|
|
||||||
</TableHead>
|
|
||||||
{!isSmallScreen && (
|
|
||||||
<TableHead className="w-[35%] p-1">
|
|
||||||
{localize('com_nav_archive_created_at')}
|
|
||||||
</TableHead>
|
|
||||||
)}
|
|
||||||
<TableHead className={cn('p-1 text-right', isSmallScreen ? 'w-[30%]' : 'w-[15%]')}>
|
|
||||||
{localize('com_assistants_actions')}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{conversations.map((conversation: TConversation) => (
|
|
||||||
<TableRow key={conversation.conversationId} className="hover:bg-transparent">
|
|
||||||
<TableCell className="py-3 text-text-primary">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex max-w-full"
|
|
||||||
aria-label="Open conversation in a new tab"
|
|
||||||
onClick={() => {
|
|
||||||
const conversationId = conversation.conversationId ?? '';
|
|
||||||
if (!conversationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleChatClick(conversationId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageCircle className="mr-1 h-5 min-w-[20px]" />
|
|
||||||
<u className="truncate">{conversation.title}</u>
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
{!isSmallScreen && (
|
|
||||||
<TableCell className="p-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div className="flex justify-start text-text-secondary">
|
|
||||||
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 p-1',
|
|
||||||
isSmallScreen ? 'justify-end' : 'justify-end gap-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_ui_unarchive')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
aria-label="Unarchive conversation"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn('size-8', isSmallScreen && 'size-7')}
|
|
||||||
onClick={() => {
|
|
||||||
const conversationId = conversation.conversationId ?? '';
|
|
||||||
if (!conversationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleUnarchive(conversationId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArchiveRestore className={cn('size-4', isSmallScreen && 'size-3.5')} />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OGDialog>
|
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogContent
|
||||||
<TooltipAnchor
|
title={localize('com_ui_delete_confirm') + ' ' + (deleteConversation?.title ?? '')}
|
||||||
description={localize('com_ui_delete')}
|
className="w-11/12 max-w-md"
|
||||||
render={
|
>
|
||||||
<Button
|
<OGDialogHeader>
|
||||||
type="button"
|
<OGDialogTitle>
|
||||||
aria-label="Delete archived conversation"
|
{localize('com_ui_delete_confirm')} <strong>{deleteConversation?.title}</strong>
|
||||||
variant="ghost"
|
</OGDialogTitle>
|
||||||
size="icon"
|
</OGDialogHeader>
|
||||||
className={cn('size-8', isSmallScreen && 'size-7')}
|
<div className="flex justify-end gap-4 pt-4">
|
||||||
>
|
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
|
||||||
<TrashIcon className={cn('size-4', isSmallScreen && 'size-3.5')} />
|
{localize('com_ui_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
<Button
|
||||||
/>
|
variant="destructive"
|
||||||
</OGDialogTrigger>
|
onClick={() =>
|
||||||
<DeleteConversationDialog
|
deleteMutation.mutate({
|
||||||
conversationId={conversation.conversationId ?? ''}
|
conversationId: deleteConversation?.conversationId ?? '',
|
||||||
retainView={refetch}
|
})
|
||||||
title={conversation.title ?? ''}
|
}
|
||||||
/>
|
disabled={deleteMutation.isLoading}
|
||||||
</OGDialog>
|
>
|
||||||
</TableCell>
|
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
||||||
</TableRow>
|
</Button>
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-6 px-2 py-4">
|
|
||||||
<div className="text-sm font-bold text-text-primary">
|
|
||||||
{localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{/* <Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Go to the previous 10 pages"
|
|
||||||
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronsLeft className="size-4" />
|
|
||||||
</Button> */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Go to the previous page"
|
|
||||||
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Go to the next page"
|
|
||||||
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{/* <Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Go to the next 10 pages"
|
|
||||||
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<ChevronsRight className="size-4" />
|
|
||||||
</Button> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</OGDialogContent>
|
||||||
)}
|
</OGDialog>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export const ThemeSelector = ({
|
||||||
options={themeOptions}
|
options={themeOptions}
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
testId="theme-selector"
|
testId="theme-selector"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -112,6 +113,7 @@ export const LangSelector = ({
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
sizeClasses="[--anchor-max-height:256px]"
|
sizeClasses="[--anchor-max-height:256px]"
|
||||||
options={languageOptions}
|
options={languageOptions}
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
||||||
options={endpointOptions}
|
options={endpointOptions}
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
testId="EngineSTTDropdown"
|
testId="EngineSTTDropdown"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ import {
|
||||||
EngineSTTDropdown,
|
EngineSTTDropdown,
|
||||||
DecibelSelector,
|
DecibelSelector,
|
||||||
} from './STT';
|
} from './STT';
|
||||||
|
import { useOnClickOutside, useMediaQuery, useLocalize } from '~/hooks';
|
||||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||||
import { useOnClickOutside, useMediaQuery } from '~/hooks';
|
|
||||||
import { cn, logger } from '~/utils';
|
import { cn, logger } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
function Speech() {
|
function Speech() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
const [confirmClear, setConfirmClear] = useState(false);
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
const { data } = useGetCustomConfigSpeechQuery();
|
const { data } = useGetCustomConfigSpeechQuery();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||||
|
|
@ -158,7 +160,7 @@ function Speech() {
|
||||||
style={{ userSelect: 'none' }}
|
style={{ userSelect: 'none' }}
|
||||||
>
|
>
|
||||||
<Lightbulb />
|
<Lightbulb />
|
||||||
Simple
|
{localize('com_ui_simple')}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
onClick={() => setAdvancedMode(true)}
|
onClick={() => setAdvancedMode(true)}
|
||||||
|
|
@ -171,7 +173,7 @@ function Speech() {
|
||||||
style={{ userSelect: 'none' }}
|
style={{ userSelect: 'none' }}
|
||||||
>
|
>
|
||||||
<Cog />
|
<Cog />
|
||||||
Advanced
|
{localize('com_ui_advanced')}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
testId="EngineTTSDropdown"
|
testId="EngineTTSDropdown"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import {
|
import {
|
||||||
Row,
|
Row,
|
||||||
|
|
@ -23,11 +24,13 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
AnimatedSearchInput,
|
AnimatedSearchInput,
|
||||||
|
Skeleton,
|
||||||
} from './';
|
} from './';
|
||||||
import { TrashIcon, Spinner } from '~/components/svg';
|
import { TrashIcon, Spinner } from '~/components/svg';
|
||||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
|
||||||
import { LocalizeFunction } from '~/common';
|
import { LocalizeFunction } from '~/common';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||||
meta?: {
|
meta?: {
|
||||||
|
|
@ -77,6 +80,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
showCheckboxes?: boolean;
|
showCheckboxes?: boolean;
|
||||||
onFilterChange?: (value: string) => void;
|
onFilterChange?: (value: string) => void;
|
||||||
filterValue?: string;
|
filterValue?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TableRowComponent = <TData, TValue>({
|
const TableRowComponent = <TData, TValue>({
|
||||||
|
|
@ -103,17 +107,11 @@ const TableRowComponent = <TData, TValue>({
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
className={`
|
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
|
||||||
motion-safe:animate-fadeIn border-b
|
|
||||||
border-border-light transition-all duration-300
|
|
||||||
ease-out
|
|
||||||
hover:bg-surface-secondary
|
|
||||||
${isSearching ? 'opacity-50' : 'opacity-100'}
|
|
||||||
${isSearching ? 'scale-98' : 'scale-100'}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${index * 20}ms`,
|
animationDelay: `${index * 20}ms`,
|
||||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||||
|
opacity: isSearching ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
|
@ -132,12 +130,7 @@ const TableRowComponent = <TData, TValue>({
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={`
|
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||||
w-0 max-w-0 px-2 py-1 align-middle text-xs
|
|
||||||
transition-all duration-300 sm:px-4
|
|
||||||
sm:py-2 sm:text-sm
|
|
||||||
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
|
|
||||||
`}
|
|
||||||
style={getColumnStyle(
|
style={getColumnStyle(
|
||||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||||
isSmallScreen,
|
isSmallScreen,
|
||||||
|
|
@ -178,7 +171,7 @@ const DeleteButton = memo(
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isSmallScreen: boolean;
|
isSmallScreen: boolean;
|
||||||
localize:LocalizeFunction;
|
localize: LocalizeFunction;
|
||||||
}) => {
|
}) => {
|
||||||
if (!onDelete) {
|
if (!onDelete) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -217,12 +210,14 @@ export default function DataTable<TData, TValue>({
|
||||||
showCheckboxes = true,
|
showCheckboxes = true,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
filterValue,
|
filterValue,
|
||||||
|
isLoading,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
@ -342,10 +337,35 @@ export default function DataTable<TData, TValue>({
|
||||||
}
|
}
|
||||||
}, [onDelete, table]);
|
}, [onDelete, table]);
|
||||||
|
|
||||||
|
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
|
||||||
|
|
||||||
|
const skeletons = Array.from({ length: 13 }, (_, index) => {
|
||||||
|
const randomWidth = getRandomWidth();
|
||||||
|
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
|
||||||
|
{tableColumns.map((column, columnIndex) => {
|
||||||
|
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
|
||||||
|
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
|
||||||
|
<Skeleton
|
||||||
|
className="h-6"
|
||||||
|
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||||
{/* Table controls */}
|
{/* Table controls */}
|
||||||
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||||
{enableRowSelection && showCheckboxes && (
|
{enableRowSelection && showCheckboxes && (
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|
@ -355,7 +375,7 @@ export default function DataTable<TData, TValue>({
|
||||||
localize={localize}
|
localize={localize}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filterColumn !== undefined && table.getColumn(filterColumn) && (
|
{filterColumn !== undefined && table.getColumn(filterColumn) && isSearchEnabled && (
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<AnimatedSearchInput
|
<AnimatedSearchInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -411,6 +431,8 @@ export default function DataTable<TData, TValue>({
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoading && skeletons}
|
||||||
|
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index];
|
const row = rows[virtualRow.index];
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export const useGetToolCalls = <TData = t.ToolCallResults>(
|
||||||
enabled:
|
enabled:
|
||||||
conversationId.length > 0 &&
|
conversationId.length > 0 &&
|
||||||
conversationId !== Constants.NEW_CONVO &&
|
conversationId !== Constants.NEW_CONVO &&
|
||||||
|
conversationId !== Constants.PENDING_CONVO &&
|
||||||
conversationId !== Constants.SEARCH,
|
conversationId !== Constants.SEARCH,
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
|
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
|
||||||
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
|
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
|
||||||
import type * as t from 'librechat-data-provider';
|
import type * as t from 'librechat-data-provider';
|
||||||
import { useConversationTagsQuery, useConversationsInfiniteQuery } from './queries';
|
|
||||||
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
|
|
||||||
import { updateConversationTag } from '~/utils/conversationTags';
|
|
||||||
import { normalizeData } from '~/utils/collection';
|
|
||||||
import {
|
import {
|
||||||
logger,
|
logger,
|
||||||
/* Conversations */
|
/* Conversations */
|
||||||
addConversation,
|
addConvoToAllQueries,
|
||||||
updateConvoFields,
|
updateConvoInAllQueries,
|
||||||
updateConversation,
|
removeConvoFromAllQueries,
|
||||||
deleteConversation,
|
|
||||||
clearConversationStorage,
|
|
||||||
} from '~/utils';
|
} from '~/utils';
|
||||||
|
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
|
||||||
|
import { updateConversationTag } from '~/utils/conversationTags';
|
||||||
|
import { useConversationTagsQuery } from './queries';
|
||||||
|
|
||||||
export type TGenTitleMutation = UseMutationResult<
|
export type TGenTitleMutation = UseMutationResult<
|
||||||
t.TGenTitleResponse,
|
t.TGenTitleResponse,
|
||||||
|
|
@ -28,29 +25,19 @@ export type TGenTitleMutation = UseMutationResult<
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Conversations */
|
|
||||||
export const useGenTitleMutation = (): TGenTitleMutation => {
|
export const useGenTitleMutation = (): TGenTitleMutation => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation((payload: t.TGenTitleRequest) => dataService.genTitle(payload), {
|
return useMutation((payload: t.TGenTitleRequest) => dataService.genTitle(payload), {
|
||||||
onSuccess: (response, vars) => {
|
onSuccess: (response, vars) => {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
[QueryKeys.conversation, vars.conversationId],
|
[QueryKeys.conversation, vars.conversationId],
|
||||||
(convo: t.TConversation | undefined) => {
|
(convo: t.TConversation | undefined) =>
|
||||||
if (!convo) {
|
convo ? { ...convo, title: response.title } : convo,
|
||||||
return convo;
|
|
||||||
}
|
|
||||||
return { ...convo, title: response.title };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
updateConvoInAllQueries(queryClient, vars.conversationId, (c) => ({
|
||||||
if (!convoData) {
|
...c,
|
||||||
return convoData;
|
title: response.title,
|
||||||
}
|
}));
|
||||||
return updateConvoFields(convoData, {
|
|
||||||
conversationId: vars.conversationId,
|
|
||||||
title: response.title,
|
|
||||||
} as t.TConversation);
|
|
||||||
});
|
|
||||||
document.title = response.title;
|
document.title = response.title;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -70,20 +57,12 @@ export const useUpdateConversationMutation = (
|
||||||
{
|
{
|
||||||
onSuccess: (updatedConvo) => {
|
onSuccess: (updatedConvo) => {
|
||||||
queryClient.setQueryData([QueryKeys.conversation, id], updatedConvo);
|
queryClient.setQueryData([QueryKeys.conversation, id], updatedConvo);
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
updateConvoInAllQueries(queryClient, id, () => updatedConvo);
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
return updateConversation(convoData, updatedConvo);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or remove tags for a conversation
|
|
||||||
*/
|
|
||||||
export const useTagConversationMutation = (
|
export const useTagConversationMutation = (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options?: t.updateTagsInConvoOptions,
|
options?: t.updateTagsInConvoOptions,
|
||||||
|
|
@ -95,12 +74,8 @@ export const useTagConversationMutation = (
|
||||||
dataService.addTagToConversation(conversationId, payload),
|
dataService.addTagToConversation(conversationId, payload),
|
||||||
{
|
{
|
||||||
onSuccess: (updatedTags, ...rest) => {
|
onSuccess: (updatedTags, ...rest) => {
|
||||||
// Because the logic for calculating the bookmark count is complex,
|
|
||||||
// the client does not perform the calculation,
|
|
||||||
// but instead refetch the data from the API.
|
|
||||||
query.refetch();
|
query.refetch();
|
||||||
updateTagsInConversation(conversationId, updatedTags);
|
updateTagsInConversation(conversationId, updatedTags);
|
||||||
|
|
||||||
options?.onSuccess?.(updatedTags, ...rest);
|
options?.onSuccess?.(updatedTags, ...rest);
|
||||||
},
|
},
|
||||||
onError: options?.onError,
|
onError: options?.onError,
|
||||||
|
|
@ -109,8 +84,8 @@ export const useTagConversationMutation = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useArchiveConversationMutation = (
|
export const useArchiveConvoMutation = (
|
||||||
id: string,
|
options?: t.ArchiveConversationOptions,
|
||||||
): UseMutationResult<
|
): UseMutationResult<
|
||||||
t.TArchiveConversationResponse,
|
t.TArchiveConversationResponse,
|
||||||
unknown,
|
unknown,
|
||||||
|
|
@ -118,118 +93,73 @@ export const useArchiveConversationMutation = (
|
||||||
unknown
|
unknown
|
||||||
> => {
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { refetch } = useConversationsInfiniteQuery();
|
const convoQueryKey = [QueryKeys.allConversations];
|
||||||
const { refetch: archiveRefetch } = useConversationsInfiniteQuery({
|
const archivedConvoQueryKey = [QueryKeys.archivedConversations];
|
||||||
pageNumber: '1', // dummy value not used to refetch
|
const { onMutate, onError, onSettled, onSuccess, ..._options } = options || {};
|
||||||
isArchived: true,
|
|
||||||
});
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
|
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
|
||||||
{
|
{
|
||||||
onSuccess: (_data, vars) => {
|
onMutate,
|
||||||
|
onSuccess: (_data, vars, context) => {
|
||||||
const isArchived = vars.isArchived === true;
|
const isArchived = vars.isArchived === true;
|
||||||
if (isArchived) {
|
|
||||||
queryClient.setQueryData([QueryKeys.conversation, id], null);
|
|
||||||
} else {
|
|
||||||
queryClient.setQueryData([QueryKeys.conversation, id], _data);
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
removeConvoFromAllQueries(queryClient, vars.conversationId);
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
const pageSize = convoData.pages[0].pageSize as number;
|
|
||||||
|
|
||||||
return normalizeData(
|
const archivedQueries = queryClient
|
||||||
isArchived ? deleteConversation(convoData, id) : addConversation(convoData, _data),
|
.getQueryCache()
|
||||||
'conversations',
|
.findAll([QueryKeys.archivedConversations], { exact: false });
|
||||||
pageSize,
|
|
||||||
|
for (const query of archivedQueries) {
|
||||||
|
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
|
||||||
|
query.queryKey,
|
||||||
|
(oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
if (isArchived) {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...oldData.pages[0],
|
||||||
|
conversations: [_data, ...oldData.pages[0].conversations],
|
||||||
|
},
|
||||||
|
...oldData.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pages: oldData.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter(
|
||||||
|
(conv) => conv.conversationId !== vars.conversationId,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (isArchived) {
|
|
||||||
const current = queryClient.getQueryData<t.ConversationData>([
|
|
||||||
QueryKeys.allConversations,
|
|
||||||
]);
|
|
||||||
refetch({ refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData<t.ConversationData>(
|
queryClient.setQueryData(
|
||||||
[QueryKeys.archivedConversations],
|
[QueryKeys.conversation, vars.conversationId],
|
||||||
(convoData) => {
|
isArchived ? null : _data,
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
const pageSize = convoData.pages[0].pageSize as number;
|
|
||||||
return normalizeData(
|
|
||||||
isArchived ? addConversation(convoData, _data) : deleteConversation(convoData, id),
|
|
||||||
'conversations',
|
|
||||||
pageSize,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isArchived) {
|
onSuccess?.(_data, vars, context);
|
||||||
const currentArchive = queryClient.getQueryData<t.ConversationData>([
|
|
||||||
QueryKeys.archivedConversations,
|
|
||||||
]);
|
|
||||||
archiveRefetch({
|
|
||||||
refetchPage: (page, index) => index === (currentArchive?.pages.length ?? 1) - 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
onError,
|
||||||
);
|
onSettled: () => {
|
||||||
};
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: convoQueryKey,
|
||||||
export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => {
|
refetchPage: (_, index) => index === 0,
|
||||||
const queryClient = useQueryClient();
|
});
|
||||||
const { onSuccess, ..._options } = options ?? {};
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: archivedConvoQueryKey,
|
||||||
return useMutation<t.TArchiveConversationResponse, unknown, t.TArchiveConversationRequest>(
|
refetchPage: (_, index) => index === 0,
|
||||||
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
|
|
||||||
{
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
const { conversationId } = vars;
|
|
||||||
const isArchived = vars.isArchived === true;
|
|
||||||
if (isArchived) {
|
|
||||||
queryClient.setQueryData([QueryKeys.conversation, conversationId], null);
|
|
||||||
} else {
|
|
||||||
queryClient.setQueryData([QueryKeys.conversation, conversationId], _data);
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
const pageSize = convoData.pages[0].pageSize as number;
|
|
||||||
return normalizeData(
|
|
||||||
isArchived
|
|
||||||
? deleteConversation(convoData, conversationId)
|
|
||||||
: addConversation(convoData, _data),
|
|
||||||
'conversations',
|
|
||||||
pageSize,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueryData<t.ConversationData>(
|
|
||||||
[QueryKeys.archivedConversations],
|
|
||||||
(convoData) => {
|
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
const pageSize = convoData.pages[0].pageSize as number;
|
|
||||||
return normalizeData(
|
|
||||||
isArchived
|
|
||||||
? addConversation(convoData, _data)
|
|
||||||
: deleteConversation(convoData, conversationId),
|
|
||||||
'conversations',
|
|
||||||
pageSize,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onSuccess?.(_data, vars);
|
|
||||||
},
|
},
|
||||||
..._options,
|
..._options,
|
||||||
},
|
},
|
||||||
|
|
@ -457,20 +387,21 @@ export const useDeleteTagInConversations = () => {
|
||||||
QueryKeys.allConversations,
|
QueryKeys.allConversations,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const conversationIdsWithTag = [] as string[];
|
const conversationIdsWithTag: string[] = [];
|
||||||
|
|
||||||
// remove deleted tag from conversations
|
// Remove deleted tag from conversations
|
||||||
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
|
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
|
||||||
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
|
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
|
||||||
const page = newData.pages[pageIndex];
|
const page = newData.pages[pageIndex];
|
||||||
page.conversations = page.conversations.map((conversation) => {
|
page.conversations = page.conversations.map((conversation) => {
|
||||||
if (
|
if (
|
||||||
conversation.conversationId != null &&
|
|
||||||
conversation.conversationId &&
|
conversation.conversationId &&
|
||||||
conversation.tags?.includes(deletedTag) === true
|
'tags' in conversation &&
|
||||||
|
Array.isArray(conversation.tags) &&
|
||||||
|
conversation.tags.includes(deletedTag)
|
||||||
) {
|
) {
|
||||||
conversationIdsWithTag.push(conversation.conversationId);
|
conversationIdsWithTag.push(conversation.conversationId);
|
||||||
conversation.tags = conversation.tags.filter((t) => t !== deletedTag);
|
conversation.tags = conversation.tags.filter((tag: string) => tag !== deletedTag);
|
||||||
}
|
}
|
||||||
return conversation;
|
return conversation;
|
||||||
});
|
});
|
||||||
|
|
@ -487,8 +418,8 @@ export const useDeleteTagInConversations = () => {
|
||||||
QueryKeys.conversation,
|
QueryKeys.conversation,
|
||||||
conversationId,
|
conversationId,
|
||||||
]);
|
]);
|
||||||
if (conversationData && conversationData.tags) {
|
if (conversationData && 'tags' in conversationData && Array.isArray(conversationData.tags)) {
|
||||||
conversationData.tags = conversationData.tags.filter((t) => t !== deletedTag);
|
conversationData.tags = conversationData.tags.filter((tag: string) => tag !== deletedTag);
|
||||||
queryClient.setQueryData<t.TConversation>(
|
queryClient.setQueryData<t.TConversation>(
|
||||||
[QueryKeys.conversation, conversationId],
|
[QueryKeys.conversation, conversationId],
|
||||||
conversationData,
|
conversationData,
|
||||||
|
|
@ -498,7 +429,7 @@ export const useDeleteTagInConversations = () => {
|
||||||
};
|
};
|
||||||
return deleteTagInAllConversation;
|
return deleteTagInAllConversation;
|
||||||
};
|
};
|
||||||
// Delete a tag
|
|
||||||
export const useDeleteConversationTagMutation = (
|
export const useDeleteConversationTagMutation = (
|
||||||
options?: t.DeleteConversationTagOptions,
|
options?: t.DeleteConversationTagOptions,
|
||||||
): UseMutationResult<t.TConversationTagResponse, unknown, string, void> => {
|
): UseMutationResult<t.TConversationTagResponse, unknown, string, void> => {
|
||||||
|
|
@ -532,40 +463,67 @@ export const useDeleteConversationMutation = (
|
||||||
unknown
|
unknown
|
||||||
> => {
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { refetch } = useConversationsInfiniteQuery();
|
|
||||||
const { onSuccess, ..._options } = options || {};
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(payload: t.TDeleteConversationRequest) => dataService.deleteConversation(payload),
|
(payload: t.TDeleteConversationRequest) =>
|
||||||
|
dataService.deleteConversation(payload) as Promise<t.TDeleteConversationResponse>,
|
||||||
{
|
{
|
||||||
onSuccess: (_data, vars, context) => {
|
onMutate: async () => {
|
||||||
const conversationId = vars.conversationId ?? '';
|
await queryClient.cancelQueries([QueryKeys.allConversations]);
|
||||||
if (!conversationId) {
|
await queryClient.cancelQueries([QueryKeys.archivedConversations]);
|
||||||
return;
|
// could store old state if needed for rollback
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// TODO: CHECK THIS, no-op; restore if needed
|
||||||
|
},
|
||||||
|
onSuccess: (data, vars, context) => {
|
||||||
|
if (vars.conversationId) {
|
||||||
|
removeConvoFromAllQueries(queryClient, vars.conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (convoData: t.ConversationData | undefined) => {
|
// Also remove from all archivedConversations caches
|
||||||
if (!convoData) {
|
const archivedQueries = queryClient
|
||||||
return convoData;
|
.getQueryCache()
|
||||||
}
|
.findAll([QueryKeys.archivedConversations], { exact: false });
|
||||||
return normalizeData(
|
|
||||||
deleteConversation(convoData, conversationId),
|
|
||||||
'conversations',
|
|
||||||
Number(convoData.pages[0].pageSize),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
queryClient.setQueryData([QueryKeys.conversation, conversationId], null);
|
for (const query of archivedQueries) {
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], handleDelete);
|
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
|
||||||
queryClient.setQueryData<t.ConversationData>(
|
query.queryKey,
|
||||||
[QueryKeys.archivedConversations],
|
(oldData) => {
|
||||||
handleDelete,
|
if (!oldData) {
|
||||||
);
|
return oldData;
|
||||||
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.allConversations]);
|
}
|
||||||
refetch({ refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1 });
|
return {
|
||||||
onSuccess?.(_data, vars, context);
|
...oldData,
|
||||||
clearConversationStorage(conversationId);
|
pages: oldData.pages
|
||||||
|
.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter(
|
||||||
|
(conv) => conv.conversationId !== vars.conversationId,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((page) => page.conversations.length > 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: [QueryKeys.conversation, vars.conversationId],
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QueryKeys.allConversations],
|
||||||
|
refetchPage: (_, index) => index === 0,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QueryKeys.archivedConversations],
|
||||||
|
refetchPage: (_, index) => index === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
..._options,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -577,24 +535,23 @@ export const useDuplicateConversationMutation = (
|
||||||
const { onSuccess, ..._options } = options ?? {};
|
const { onSuccess, ..._options } = options ?? {};
|
||||||
return useMutation((payload) => dataService.duplicateConversation(payload), {
|
return useMutation((payload) => dataService.duplicateConversation(payload), {
|
||||||
onSuccess: (data, vars, context) => {
|
onSuccess: (data, vars, context) => {
|
||||||
const originalId = vars.conversationId ?? '';
|
const duplicatedConversation = data.conversation;
|
||||||
if (originalId.length === 0) {
|
if (!duplicatedConversation?.conversationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
[QueryKeys.conversation, data.conversation.conversationId],
|
[QueryKeys.conversation, duplicatedConversation.conversationId],
|
||||||
data.conversation,
|
duplicatedConversation,
|
||||||
);
|
);
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
addConvoToAllQueries(queryClient, duplicatedConversation);
|
||||||
if (!convoData) {
|
queryClient.setQueryData(
|
||||||
return convoData;
|
[QueryKeys.messages, duplicatedConversation.conversationId],
|
||||||
}
|
|
||||||
return addConversation(convoData, data.conversation);
|
|
||||||
});
|
|
||||||
queryClient.setQueryData<t.TMessage[]>(
|
|
||||||
[QueryKeys.messages, data.conversation.conversationId],
|
|
||||||
data.messages,
|
data.messages,
|
||||||
);
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QueryKeys.allConversations],
|
||||||
|
refetchPage: (_, index) => index === 0,
|
||||||
|
});
|
||||||
onSuccess?.(data, vars, context);
|
onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
..._options,
|
..._options,
|
||||||
|
|
@ -606,25 +563,25 @@ export const useForkConvoMutation = (
|
||||||
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
|
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { onSuccess, ..._options } = options || {};
|
const { onSuccess, ..._options } = options || {};
|
||||||
|
|
||||||
return useMutation((payload: t.TForkConvoRequest) => dataService.forkConversation(payload), {
|
return useMutation((payload: t.TForkConvoRequest) => dataService.forkConversation(payload), {
|
||||||
onSuccess: (data, vars, context) => {
|
onSuccess: (data, vars, context) => {
|
||||||
if (!vars.conversationId) {
|
if (!vars.conversationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(
|
const forkedConversation = data.conversation;
|
||||||
[QueryKeys.conversation, data.conversation.conversationId],
|
const forkedConversationId = forkedConversation.conversationId;
|
||||||
data.conversation,
|
if (!forkedConversationId) {
|
||||||
);
|
return;
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
}
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
queryClient.setQueryData([QueryKeys.conversation, forkedConversationId], forkedConversation);
|
||||||
}
|
addConvoToAllQueries(queryClient, forkedConversation);
|
||||||
return addConversation(convoData, data.conversation);
|
queryClient.setQueryData([QueryKeys.messages, forkedConversationId], data.messages);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QueryKeys.allConversations],
|
||||||
|
refetchPage: (_, index) => index === 0,
|
||||||
});
|
});
|
||||||
queryClient.setQueryData<t.TMessage[]>(
|
|
||||||
[QueryKeys.messages, data.conversation.conversationId],
|
|
||||||
data.messages,
|
|
||||||
);
|
|
||||||
onSuccess?.(data, vars, context);
|
onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
..._options,
|
..._options,
|
||||||
|
|
@ -899,7 +856,6 @@ export const useUploadAssistantAvatarMutation = (
|
||||||
unknown // context
|
unknown // context
|
||||||
> => {
|
> => {
|
||||||
return useMutation([MutationKeys.assistantAvatarUpload], {
|
return useMutation([MutationKeys.assistantAvatarUpload], {
|
||||||
|
|
||||||
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
|
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
|
||||||
dataService.uploadAssistantAvatar(variables),
|
dataService.uploadAssistantAvatar(variables),
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import {
|
||||||
defaultOrderQuery,
|
defaultOrderQuery,
|
||||||
defaultAssistantsVersion,
|
defaultAssistantsVersion,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
|
InfiniteData,
|
||||||
UseInfiniteQueryOptions,
|
UseInfiniteQueryOptions,
|
||||||
QueryObserverResult,
|
QueryObserverResult,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
|
|
@ -19,6 +19,8 @@ import type {
|
||||||
TPlugin,
|
TPlugin,
|
||||||
ConversationListResponse,
|
ConversationListResponse,
|
||||||
ConversationListParams,
|
ConversationListParams,
|
||||||
|
SearchConversationListResponse,
|
||||||
|
SearchConversationListParams,
|
||||||
Assistant,
|
Assistant,
|
||||||
AssistantListParams,
|
AssistantListParams,
|
||||||
AssistantListResponse,
|
AssistantListResponse,
|
||||||
|
|
@ -28,8 +30,6 @@ import type {
|
||||||
SharedLinksListParams,
|
SharedLinksListParams,
|
||||||
SharedLinksResponse,
|
SharedLinksResponse,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { findPageForConversation } from '~/utils';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
export const useGetPresetsQuery = (
|
export const useGetPresetsQuery = (
|
||||||
config?: UseQueryOptions<TPreset[]>,
|
config?: UseQueryOptions<TPreset[]>,
|
||||||
|
|
@ -63,25 +63,23 @@ export const useGetConvoIdQuery = (
|
||||||
config?: UseQueryOptions<t.TConversation>,
|
config?: UseQueryOptions<t.TConversation>,
|
||||||
): QueryObserverResult<t.TConversation> => {
|
): QueryObserverResult<t.TConversation> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useQuery<t.TConversation>(
|
return useQuery<t.TConversation>(
|
||||||
[QueryKeys.conversation, id],
|
[QueryKeys.conversation, id],
|
||||||
() => {
|
() => {
|
||||||
const defaultQuery = () => dataService.getConversationById(id);
|
// Try to find in all fetched infinite pages
|
||||||
const convosQuery = queryClient.getQueryData<t.ConversationData>([
|
const convosQuery = queryClient.getQueryData<
|
||||||
QueryKeys.allConversations,
|
InfiniteData<import('~/utils').ConversationCursorData>
|
||||||
]);
|
>([QueryKeys.allConversations]);
|
||||||
|
const found = convosQuery?.pages
|
||||||
|
.flatMap((page) => page.conversations)
|
||||||
|
.find((c) => c.conversationId === id);
|
||||||
|
|
||||||
if (!convosQuery) {
|
if (found) {
|
||||||
return defaultQuery();
|
return found;
|
||||||
}
|
}
|
||||||
|
// Otherwise, fetch from API
|
||||||
const { pageIndex, index } = findPageForConversation(convosQuery, { conversationId: id });
|
return dataService.getConversationById(id);
|
||||||
|
|
||||||
if (pageIndex > -1 && index > -1) {
|
|
||||||
return convosQuery.pages[pageIndex].conversations[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultQuery();
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|
@ -93,19 +91,21 @@ export const useGetConvoIdQuery = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSearchInfiniteQuery = (
|
export const useSearchInfiniteQuery = (
|
||||||
params?: ConversationListParams & { searchQuery?: string },
|
params?: SearchConversationListParams,
|
||||||
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
|
config?: UseInfiniteQueryOptions<SearchConversationListResponse, unknown>,
|
||||||
) => {
|
) => {
|
||||||
return useInfiniteQuery<ConversationListResponse, unknown>(
|
return useInfiniteQuery<SearchConversationListResponse, unknown>(
|
||||||
[QueryKeys.searchConversations, params], // Include the searchQuery in the query key
|
[QueryKeys.searchConversations, params],
|
||||||
({ pageParam = '1' }) =>
|
({ pageParam = null }) =>
|
||||||
dataService.listConversationsByQuery({ ...params, pageNumber: pageParam }),
|
dataService
|
||||||
|
.listConversations({
|
||||||
|
...params,
|
||||||
|
search: params?.search ?? '',
|
||||||
|
cursor: pageParam?.toString(),
|
||||||
|
})
|
||||||
|
.then((res) => ({ ...res })) as Promise<SearchConversationListResponse>,
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
const currentPageNumber = Number(lastPage.pageNumber);
|
|
||||||
const totalPages = Number(lastPage.pages);
|
|
||||||
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|
@ -115,33 +115,31 @@ export const useSearchInfiniteQuery = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useConversationsInfiniteQuery = (
|
export const useConversationsInfiniteQuery = (
|
||||||
params?: ConversationListParams,
|
params: ConversationListParams,
|
||||||
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
|
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
|
||||||
) => {
|
) => {
|
||||||
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
|
const { isArchived, sortBy, sortDirection, tags, search } = params;
|
||||||
return useInfiniteQuery<ConversationListResponse, unknown>(
|
|
||||||
params?.isArchived === true ? [QueryKeys.archivedConversations] : [QueryKeys.allConversations],
|
return useInfiniteQuery<ConversationListResponse>({
|
||||||
({ pageParam = '' }) =>
|
queryKey: [
|
||||||
|
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
|
||||||
|
{ isArchived, sortBy, sortDirection, tags, search },
|
||||||
|
],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
dataService.listConversations({
|
dataService.listConversations({
|
||||||
...params,
|
isArchived,
|
||||||
pageNumber: pageParam?.toString(),
|
sortBy,
|
||||||
isArchived: params?.isArchived ?? false,
|
sortDirection,
|
||||||
tags: params?.tags || [],
|
tags,
|
||||||
|
search,
|
||||||
|
cursor: pageParam?.toString(),
|
||||||
}),
|
}),
|
||||||
{
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
getNextPageParam: (lastPage) => {
|
keepPreviousData: true,
|
||||||
const currentPageNumber = Number(lastPage.pageNumber);
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
const totalPages = Number(lastPage.pages); // Convert totalPages to a number
|
cacheTime: 30 * 60 * 1000, // 30 minutes
|
||||||
// If the current page number is less than total pages, return the next page number
|
...config,
|
||||||
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
|
});
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
...config,
|
|
||||||
enabled: (config?.enabled ?? true) === true && queriesEnabled,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSharedLinksQuery = (
|
export const useSharedLinksQuery = (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
TConversation,
|
TConversation,
|
||||||
TEndpointOption,
|
TEndpointOption,
|
||||||
TEndpointsConfig,
|
TEndpointsConfig,
|
||||||
|
EndpointSchemaKey,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { SetterOrUpdater } from 'recoil';
|
import type { SetterOrUpdater } from 'recoil';
|
||||||
import type { TAskFunction, ExtendedFile } from '~/common';
|
import type { TAskFunction, ExtendedFile } from '~/common';
|
||||||
|
|
@ -160,8 +161,8 @@ export default function useChatFunctions({
|
||||||
|
|
||||||
// set the endpoint option
|
// set the endpoint option
|
||||||
const convo = parseCompactConvo({
|
const convo = parseCompactConvo({
|
||||||
endpoint,
|
endpoint: endpoint as EndpointSchemaKey,
|
||||||
endpointType,
|
endpointType: endpointType as EndpointSchemaKey,
|
||||||
conversation: conversation ?? {},
|
conversation: conversation ?? {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ export { default as usePresets } from './usePresets';
|
||||||
export { default as useGetSender } from './useGetSender';
|
export { default as useGetSender } from './useGetSender';
|
||||||
export { default as useDefaultConvo } from './useDefaultConvo';
|
export { default as useDefaultConvo } from './useDefaultConvo';
|
||||||
export { default as useGenerateConvo } from './useGenerateConvo';
|
export { default as useGenerateConvo } from './useGenerateConvo';
|
||||||
export { default as useArchiveHandler } from './useArchiveHandler';
|
|
||||||
export { default as useDebouncedInput } from './useDebouncedInput';
|
export { default as useDebouncedInput } from './useDebouncedInput';
|
||||||
export { default as useBookmarkSuccess } from './useBookmarkSuccess';
|
export { default as useBookmarkSuccess } from './useBookmarkSuccess';
|
||||||
export { default as useNavigateToConvo } from './useNavigateToConvo';
|
export { default as useNavigateToConvo } from './useNavigateToConvo';
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
|
||||||
import { useArchiveConversationMutation } from '~/data-provider';
|
|
||||||
import { NotificationSeverity } from '~/common';
|
|
||||||
import { useToastContext } from '~/Providers';
|
|
||||||
import useLocalize, { TranslationKeys } from '../useLocalize';
|
|
||||||
import useNewConvo from '../useNewConvo';
|
|
||||||
|
|
||||||
export default function useArchiveHandler(
|
|
||||||
conversationId: string | null,
|
|
||||||
shouldArchive: boolean,
|
|
||||||
retainView: () => void,
|
|
||||||
) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { showToast } = useToastContext();
|
|
||||||
const { newConversation } = useNewConvo();
|
|
||||||
const { conversationId: currentConvoId } = useParams();
|
|
||||||
|
|
||||||
const archiveConvoMutation = useArchiveConversationMutation(conversationId ?? '');
|
|
||||||
|
|
||||||
return async (e?: MouseEvent | FocusEvent | KeyboardEvent) => {
|
|
||||||
if (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
const convoId = conversationId ?? '';
|
|
||||||
if (!convoId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const label: TranslationKeys = shouldArchive ? 'com_ui_archive_error' : 'com_ui_unarchive_error';
|
|
||||||
archiveConvoMutation.mutate(
|
|
||||||
{ conversationId: convoId, isArchived: shouldArchive },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
|
||||||
newConversation();
|
|
||||||
navigate('/c/new', { replace: true });
|
|
||||||
}
|
|
||||||
retainView();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showToast({
|
|
||||||
message: localize(label),
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
showIcon: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
import type { SearchConversationListResponse } from 'librechat-data-provider';
|
||||||
import { useSearchInfiniteQuery, useGetSearchEnabledQuery } from '~/data-provider';
|
import { useSearchInfiniteQuery, useGetSearchEnabledQuery } from '~/data-provider';
|
||||||
import useNewConvo from '~/hooks/useNewConvo';
|
import useNewConvo from '~/hooks/useNewConvo';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function useSearchMessages({ isAuthenticated }: { isAuthenticated: boolean }) {
|
export interface UseSearchMessagesResult {
|
||||||
|
searchQuery: string;
|
||||||
|
searchQueryRes: UseInfiniteQueryResult<SearchConversationListResponse, unknown> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useSearchMessages({
|
||||||
|
isAuthenticated,
|
||||||
|
}: {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}): UseSearchMessagesResult {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
|
||||||
const { switchToConversation } = useNewConvo();
|
const { switchToConversation } = useNewConvo();
|
||||||
const searchPlaceholderConversation = useCallback(() => {
|
const searchPlaceholderConversation = useCallback(() => {
|
||||||
switchToConversation({
|
switchToConversation({
|
||||||
|
|
@ -25,11 +33,20 @@ export default function useSearchMessages({ isAuthenticated }: { isAuthenticated
|
||||||
const searchQuery = useRecoilValue(store.searchQuery);
|
const searchQuery = useRecoilValue(store.searchQuery);
|
||||||
const setIsSearchEnabled = useSetRecoilState(store.isSearchEnabled);
|
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 searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated });
|
||||||
const searchQueryRes = useSearchInfiniteQuery(
|
const searchQueryRes = useSearchInfiniteQuery(
|
||||||
{ pageNumber: pageNumber.toString(), searchQuery: searchQuery, isArchived: false },
|
{ nextCursor: null, search: debouncedSearchQuery, pageSize: 20 },
|
||||||
{ enabled: isAuthenticated && !!searchQuery.length },
|
{ enabled: isAuthenticated && !!debouncedSearchQuery },
|
||||||
) as UseInfiniteQueryResult<ConversationListResponse, unknown> | undefined;
|
) as UseInfiniteQueryResult<SearchConversationListResponse, unknown> | undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchQuery && searchQuery.length > 0) {
|
if (searchQuery && searchQuery.length > 0) {
|
||||||
|
|
@ -64,16 +81,22 @@ export default function useSearchMessages({ isAuthenticated }: { isAuthenticated
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
//we use isInitialLoading here instead of isLoading because query is disabled by default
|
// we use isInitialLoading here instead of isLoading because query is disabled by default
|
||||||
if (searchQueryRes?.data) {
|
if (searchQueryRes?.data) {
|
||||||
onSearchSuccess();
|
onSearchSuccess();
|
||||||
}
|
}
|
||||||
}, [searchQueryRes?.data, searchQueryRes?.isInitialLoading, 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 {
|
return {
|
||||||
pageNumber,
|
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setPageNumber,
|
|
||||||
searchQueryRes,
|
searchQueryRes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { QueryKeys } from 'librechat-data-provider';
|
||||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||||
import type { InfiniteData } from '@tanstack/react-query';
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { updateConvoFields } from '~/utils/convos';
|
import { updateConvoFieldsInfinite } from '~/utils/convos';
|
||||||
|
|
||||||
const useUpdateTagsInConvo = () => {
|
const useUpdateTagsInConvo = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -24,19 +24,25 @@ const useUpdateTagsInConvo = () => {
|
||||||
tags,
|
tags,
|
||||||
} as t.TConversation;
|
} as t.TConversation;
|
||||||
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
|
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
|
||||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
|
||||||
if (!convoData) {
|
[QueryKeys.allConversations],
|
||||||
return convoData;
|
(convoData) => {
|
||||||
}
|
if (!convoData) {
|
||||||
return updateConvoFields(
|
return convoData;
|
||||||
convoData,
|
}
|
||||||
{
|
return {
|
||||||
conversationId: currentConvo.conversationId,
|
...convoData,
|
||||||
tags: updatedConvo.tags,
|
pages: convoData.pages.map((page) => ({
|
||||||
} as t.TConversation,
|
...page,
|
||||||
true,
|
conversations: page.conversations.map((conversation) =>
|
||||||
);
|
conversation.conversationId === (currentConvo.conversationId ?? '')
|
||||||
});
|
? { ...conversation, tags: updatedConvo.tags }
|
||||||
|
: conversation,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// update the tag to newTag in all conversations when a tag is updated to a newTag
|
// update the tag to newTag in all conversations when a tag is updated to a newTag
|
||||||
|
|
@ -54,9 +60,15 @@ const useUpdateTagsInConvo = () => {
|
||||||
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
|
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
|
||||||
const page = newData.pages[pageIndex];
|
const page = newData.pages[pageIndex];
|
||||||
page.conversations = page.conversations.map((conversation) => {
|
page.conversations = page.conversations.map((conversation) => {
|
||||||
if (conversation.conversationId && conversation.tags?.includes(tag)) {
|
if (
|
||||||
conversationIdsWithTag.push(conversation.conversationId);
|
conversation.conversationId &&
|
||||||
conversation.tags = conversation.tags.map((t) => (t === tag ? newTag : t));
|
'tags' in conversation &&
|
||||||
|
Array.isArray((conversation as { tags?: string[] }).tags) &&
|
||||||
|
(conversation as { tags?: string[] }).tags?.includes(tag)
|
||||||
|
) {
|
||||||
|
(conversation as { tags: string[] }).tags = (conversation as { tags: string[] }).tags.map(
|
||||||
|
(t: string) => (t === tag ? newTag : t),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return conversation;
|
return conversation;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ const decodeBase64 = (base64String: string): string => {
|
||||||
export const useAutoSave = ({
|
export const useAutoSave = ({
|
||||||
conversationId,
|
conversationId,
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
files,
|
|
||||||
setFiles,
|
setFiles,
|
||||||
|
files,
|
||||||
}: {
|
}: {
|
||||||
conversationId?: string | null;
|
conversationId?: string | null;
|
||||||
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
|
@ -106,7 +106,7 @@ export const useAutoSave = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Save the draft of the current conversation before switching
|
// Save the draft of the current conversation before switching
|
||||||
if (textAreaRef.current.value === '') {
|
if (textAreaRef.current.value === '' || textAreaRef.current.value.length === 1) {
|
||||||
clearDraft(id);
|
clearDraft(id);
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|
@ -126,8 +126,7 @@ export const useAutoSave = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInput = debounce((value: string) => {
|
||||||
const value = e.target.value;
|
|
||||||
if (value && value.length > 1) {
|
if (value && value.length > 1) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`,
|
`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`,
|
||||||
|
|
@ -138,14 +137,19 @@ export const useAutoSave = ({
|
||||||
}
|
}
|
||||||
}, 750);
|
}, 750);
|
||||||
|
|
||||||
|
const eventListener = (e: Event) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
handleInput(target.value);
|
||||||
|
};
|
||||||
|
|
||||||
const textArea = textAreaRef?.current;
|
const textArea = textAreaRef?.current;
|
||||||
if (textArea) {
|
if (textArea) {
|
||||||
textArea.addEventListener('input', handleInput);
|
textArea.addEventListener('input', eventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (textArea) {
|
if (textArea) {
|
||||||
textArea.removeEventListener('input', handleInput);
|
textArea.removeEventListener('input', eventListener);
|
||||||
}
|
}
|
||||||
handleInput.cancel();
|
handleInput.cancel();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,33 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { FetchNextPageOptions, InfiniteQueryObserverResult } from '@tanstack/react-query';
|
import type { FetchNextPageOptions, InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
export default function useNavScrolling<TData>({
|
export default function useNavScrolling<TData>({
|
||||||
hasNextPage,
|
nextCursor,
|
||||||
isFetchingNextPage,
|
isFetchingNext,
|
||||||
setShowLoading,
|
setShowLoading,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: {
|
}: {
|
||||||
hasNextPage?: boolean;
|
nextCursor?: string | null;
|
||||||
isFetchingNextPage: boolean;
|
isFetchingNext: boolean;
|
||||||
setShowLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
fetchNextPage:
|
fetchNextPage?: (
|
||||||
| ((
|
options?: FetchNextPageOptions | undefined,
|
||||||
options?: FetchNextPageOptions | undefined,
|
) => Promise<InfiniteQueryObserverResult<TData, unknown>>;
|
||||||
) => Promise<InfiniteQueryObserverResult<TData, unknown>>)
|
|
||||||
| undefined;
|
|
||||||
}) {
|
}) {
|
||||||
const scrollPositionRef = useRef<number | null>(null);
|
const scrollPositionRef = useRef<number | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const fetchNext = useCallback(
|
const fetchNext = useCallback(
|
||||||
throttle(() => (fetchNextPage != null ? fetchNextPage() : () => ({})), 750, { leading: true }),
|
throttle(
|
||||||
|
() => {
|
||||||
|
if (fetchNextPage) {
|
||||||
|
return fetchNextPage();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
750,
|
||||||
|
{ leading: true },
|
||||||
|
),
|
||||||
[fetchNextPage],
|
[fetchNextPage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -31,14 +38,14 @@ export default function useNavScrolling<TData>({
|
||||||
const { scrollTop, clientHeight, scrollHeight } = containerRef.current;
|
const { scrollTop, clientHeight, scrollHeight } = containerRef.current;
|
||||||
const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97;
|
const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97;
|
||||||
|
|
||||||
if (nearBottomOfList && hasNextPage === true && !isFetchingNextPage) {
|
if (nearBottomOfList && nextCursor != null && !isFetchingNext) {
|
||||||
setShowLoading(true);
|
setShowLoading(true);
|
||||||
fetchNext();
|
fetchNext();
|
||||||
} else {
|
} else {
|
||||||
setShowLoading(false);
|
setShowLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNext, setShowLoading]);
|
}, [nextCursor, isFetchingNext, fetchNext, setShowLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
@ -47,16 +54,18 @@ export default function useNavScrolling<TData>({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container?.removeEventListener('scroll', handleScroll);
|
if (container) {
|
||||||
|
container.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [handleScroll, fetchNext]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
const moveToTop = useCallback(() => {
|
const moveToTop = useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
scrollPositionRef.current = container.scrollTop;
|
scrollPositionRef.current = container.scrollTop;
|
||||||
}
|
}
|
||||||
}, [containerRef, scrollPositionRef]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containerRef,
|
containerRef,
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,19 @@ import {
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type {
|
import type { TMessage, TConversation, EventSubmission } from 'librechat-data-provider';
|
||||||
TMessage,
|
|
||||||
TConversation,
|
|
||||||
EventSubmission,
|
|
||||||
ConversationData,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
import type { SetterOrUpdater, Resetter } from 'recoil';
|
|
||||||
import type { TResData, TFinalResData, ConvoGenerator } from '~/common';
|
import type { TResData, TFinalResData, ConvoGenerator } from '~/common';
|
||||||
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
import type { TGenTitleMutation } from '~/data-provider';
|
import type { TGenTitleMutation } from '~/data-provider';
|
||||||
|
import type { SetterOrUpdater, Resetter } from 'recoil';
|
||||||
|
import type { ConversationCursorData } from '~/utils';
|
||||||
import {
|
import {
|
||||||
scrollToEnd,
|
scrollToEnd,
|
||||||
addConversation,
|
addConvoToAllQueries,
|
||||||
|
updateConvoInAllQueries,
|
||||||
|
removeConvoFromAllQueries,
|
||||||
|
findConversationInInfinite,
|
||||||
getAllContentText,
|
getAllContentText,
|
||||||
deleteConversation,
|
|
||||||
updateConversation,
|
|
||||||
getConversationById,
|
|
||||||
} from '~/utils';
|
} from '~/utils';
|
||||||
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
|
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
|
||||||
import useContentHandler from '~/hooks/SSE/useContentHandler';
|
import useContentHandler from '~/hooks/SSE/useContentHandler';
|
||||||
|
|
@ -128,6 +125,37 @@ const createErrorMessage = ({
|
||||||
return tMessageSchema.parse(errorMessage);
|
return tMessageSchema.parse(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getConvoTitle = ({
|
||||||
|
parentId,
|
||||||
|
queryClient,
|
||||||
|
currentTitle,
|
||||||
|
conversationId,
|
||||||
|
}: {
|
||||||
|
parentId?: string | null;
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>;
|
||||||
|
currentTitle?: string | null;
|
||||||
|
conversationId?: string | null;
|
||||||
|
}): string | null | undefined => {
|
||||||
|
if (
|
||||||
|
parentId !== Constants.NO_PARENT &&
|
||||||
|
(currentTitle?.toLowerCase().includes('new chat') ?? false)
|
||||||
|
) {
|
||||||
|
const currentConvo = queryClient.getQueryData<TConversation>([
|
||||||
|
QueryKeys.conversation,
|
||||||
|
conversationId,
|
||||||
|
]);
|
||||||
|
if (currentConvo?.title) {
|
||||||
|
return currentConvo.title;
|
||||||
|
}
|
||||||
|
const convos = queryClient.getQueryData<InfiniteData<ConversationCursorData>>([
|
||||||
|
QueryKeys.allConversations,
|
||||||
|
]);
|
||||||
|
const cachedConvo = findConversationInInfinite(convos, conversationId ?? '');
|
||||||
|
return cachedConvo?.title ?? currentConvo?.title ?? null;
|
||||||
|
}
|
||||||
|
return currentTitle;
|
||||||
|
};
|
||||||
|
|
||||||
export default function useEventHandlers({
|
export default function useEventHandlers({
|
||||||
genTitle,
|
genTitle,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|
@ -186,7 +214,6 @@ export default function useEventHandlers({
|
||||||
text,
|
text,
|
||||||
plugin: plugin ?? null,
|
plugin: plugin ?? null,
|
||||||
plugins: plugins ?? [],
|
plugins: plugins ?? [],
|
||||||
// unfinished: true
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -198,7 +225,6 @@ export default function useEventHandlers({
|
||||||
text,
|
text,
|
||||||
plugin: plugin ?? null,
|
plugin: plugin ?? null,
|
||||||
plugins: plugins ?? [],
|
plugins: plugins ?? [],
|
||||||
// unfinished: true
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +236,6 @@ export default function useEventHandlers({
|
||||||
(data: TResData, submission: EventSubmission) => {
|
(data: TResData, submission: EventSubmission) => {
|
||||||
const { requestMessage, responseMessage, conversation } = data;
|
const { requestMessage, responseMessage, conversation } = data;
|
||||||
const { messages, isRegenerate = false } = submission;
|
const { messages, isRegenerate = false } = submission;
|
||||||
|
|
||||||
const convoUpdate =
|
const convoUpdate =
|
||||||
(conversation as TConversation | null) ?? (submission.conversation as TConversation);
|
(conversation as TConversation | null) ?? (submission.conversation as TConversation);
|
||||||
|
|
||||||
|
|
@ -229,12 +254,7 @@ export default function useEventHandlers({
|
||||||
|
|
||||||
const isNewConvo = conversation.conversationId !== submission.conversation.conversationId;
|
const isNewConvo = conversation.conversationId !== submission.conversation.conversationId;
|
||||||
if (isNewConvo) {
|
if (isNewConvo) {
|
||||||
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
|
removeConvoFromAllQueries(queryClient, submission.conversation.conversationId as string);
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
return deleteConversation(convoData, submission.conversation.conversationId as string);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh title
|
// refresh title
|
||||||
|
|
@ -246,11 +266,7 @@ export default function useEventHandlers({
|
||||||
|
|
||||||
if (setConversation && !isAddedRequest) {
|
if (setConversation && !isAddedRequest) {
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
const update = {
|
const update = { ...prevState, ...convoUpdate };
|
||||||
...prevState,
|
|
||||||
...convoUpdate,
|
|
||||||
};
|
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +280,6 @@ export default function useEventHandlers({
|
||||||
(data: TSyncData, submission: EventSubmission) => {
|
(data: TSyncData, submission: EventSubmission) => {
|
||||||
const { conversationId, thread_id, responseMessage, requestMessage } = data;
|
const { conversationId, thread_id, responseMessage, requestMessage } = data;
|
||||||
const { initialResponse, messages: _messages, userMessage } = submission;
|
const { initialResponse, messages: _messages, userMessage } = submission;
|
||||||
|
|
||||||
const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId);
|
const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId);
|
||||||
|
|
||||||
setMessages([
|
setMessages([
|
||||||
|
|
@ -284,17 +299,13 @@ export default function useEventHandlers({
|
||||||
let update = {} as TConversation;
|
let update = {} as TConversation;
|
||||||
if (setConversation && !isAddedRequest) {
|
if (setConversation && !isAddedRequest) {
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
let title = prevState?.title;
|
|
||||||
const parentId = requestMessage.parentMessageId;
|
const parentId = requestMessage.parentMessageId;
|
||||||
if (
|
const title = getConvoTitle({
|
||||||
parentId !== Constants.NO_PARENT &&
|
parentId,
|
||||||
(title?.toLowerCase().includes('new chat') ?? false)
|
queryClient,
|
||||||
) {
|
conversationId,
|
||||||
const convos = queryClient.getQueryData<ConversationData>([QueryKeys.allConversations]);
|
currentTitle: prevState?.title,
|
||||||
const cachedConvo = getConversationById(convos, conversationId);
|
});
|
||||||
title = cachedConvo?.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
update = tConvoUpdateSchema.parse({
|
update = tConvoUpdateSchema.parse({
|
||||||
...prevState,
|
...prevState,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -302,20 +313,14 @@ export default function useEventHandlers({
|
||||||
title,
|
title,
|
||||||
messages: [requestMessage.messageId, responseMessage.messageId],
|
messages: [requestMessage.messageId, responseMessage.messageId],
|
||||||
}) as TConversation;
|
}) as TConversation;
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
|
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
|
||||||
if (!convoData) {
|
addConvoToAllQueries(queryClient, update);
|
||||||
return convoData;
|
} else {
|
||||||
}
|
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update);
|
||||||
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
|
}
|
||||||
return addConversation(convoData, update);
|
|
||||||
} else {
|
|
||||||
return updateConversation(convoData, update);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (setConversation) {
|
} else if (setConversation) {
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
update = tConvoUpdateSchema.parse({
|
update = tConvoUpdateSchema.parse({
|
||||||
|
|
@ -371,39 +376,28 @@ export default function useEventHandlers({
|
||||||
}
|
}
|
||||||
if (setConversation && !isAddedRequest) {
|
if (setConversation && !isAddedRequest) {
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
let title = prevState?.title;
|
|
||||||
const parentId = isRegenerate ? userMessage.overrideParentMessageId : parentMessageId;
|
const parentId = isRegenerate ? userMessage.overrideParentMessageId : parentMessageId;
|
||||||
if (
|
const title = getConvoTitle({
|
||||||
parentId !== Constants.NO_PARENT &&
|
parentId,
|
||||||
(title?.toLowerCase().includes('new chat') ?? false)
|
queryClient,
|
||||||
) {
|
conversationId,
|
||||||
const convos = queryClient.getQueryData<ConversationData>([QueryKeys.allConversations]);
|
currentTitle: prevState?.title,
|
||||||
const cachedConvo = getConversationById(convos, conversationId);
|
});
|
||||||
title = cachedConvo?.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
update = tConvoUpdateSchema.parse({
|
update = tConvoUpdateSchema.parse({
|
||||||
...prevState,
|
...prevState,
|
||||||
conversationId,
|
conversationId,
|
||||||
title,
|
title,
|
||||||
}) as TConversation;
|
}) as TConversation;
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isTemporary) {
|
if (!isTemporary) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
|
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
if (parentMessageId === Constants.NO_PARENT) {
|
if (parentMessageId === Constants.NO_PARENT) {
|
||||||
return addConversation(convoData, update);
|
addConvoToAllQueries(queryClient, update);
|
||||||
} else {
|
} else {
|
||||||
return updateConversation(convoData, update);
|
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
} else if (setConversation) {
|
} else if (setConversation) {
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
update = tConvoUpdateSchema.parse({
|
update = tConvoUpdateSchema.parse({
|
||||||
|
|
@ -417,7 +411,6 @@ export default function useEventHandlers({
|
||||||
if (resetLatestMessage) {
|
if (resetLatestMessage) {
|
||||||
resetLatestMessage();
|
resetLatestMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToEnd(() => setAbortScroll(false));
|
scrollToEnd(() => setAbortScroll(false));
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
@ -447,18 +440,13 @@ export default function useEventHandlers({
|
||||||
const currentMessages = getMessages();
|
const currentMessages = getMessages();
|
||||||
/* Early return if messages are empty; i.e., the user navigated away */
|
/* Early return if messages are empty; i.e., the user navigated away */
|
||||||
if (!currentMessages || currentMessages.length === 0) {
|
if (!currentMessages || currentMessages.length === 0) {
|
||||||
return setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* a11y announcements */
|
/* a11y announcements */
|
||||||
announcePolite({
|
announcePolite({ message: 'end', isStatus: true });
|
||||||
message: 'end',
|
announcePolite({ message: getAllContentText(responseMessage) });
|
||||||
isStatus: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
announcePolite({
|
|
||||||
message: getAllContentText(responseMessage),
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
|
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
|
||||||
if (runMessages) {
|
if (runMessages) {
|
||||||
|
|
@ -471,12 +459,7 @@ export default function useEventHandlers({
|
||||||
|
|
||||||
const isNewConvo = conversation.conversationId !== submissionConvo.conversationId;
|
const isNewConvo = conversation.conversationId !== submissionConvo.conversationId;
|
||||||
if (isNewConvo) {
|
if (isNewConvo) {
|
||||||
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
|
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId as string);
|
||||||
if (!convoData) {
|
|
||||||
return convoData;
|
|
||||||
}
|
|
||||||
return deleteConversation(convoData, submissionConvo.conversationId as string);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Refresh title */
|
/* Refresh title */
|
||||||
|
|
@ -500,13 +483,18 @@ export default function useEventHandlers({
|
||||||
setConversation((prevState) => {
|
setConversation((prevState) => {
|
||||||
const update = {
|
const update = {
|
||||||
...prevState,
|
...prevState,
|
||||||
...conversation,
|
...(conversation as TConversation),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (prevState?.model != null && prevState.model !== submissionConvo.model) {
|
if (prevState?.model != null && prevState.model !== submissionConvo.model) {
|
||||||
update.model = prevState.model;
|
update.model = prevState.model;
|
||||||
}
|
}
|
||||||
|
const cachedConvo = queryClient.getQueryData<TConversation>([
|
||||||
|
QueryKeys.conversation,
|
||||||
|
conversation.conversationId,
|
||||||
|
]);
|
||||||
|
if (!cachedConvo) {
|
||||||
|
queryClient.setQueryData([QueryKeys.conversation, conversation.conversationId], update);
|
||||||
|
}
|
||||||
return update;
|
return update;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -530,7 +518,6 @@ export default function useEventHandlers({
|
||||||
const errorHandler = useCallback(
|
const errorHandler = useCallback(
|
||||||
({ data, submission }: { data?: TResData; submission: EventSubmission }) => {
|
({ data, submission }: { data?: TResData; submission: EventSubmission }) => {
|
||||||
const { messages, userMessage, initialResponse } = submission;
|
const { messages, userMessage, initialResponse } = submission;
|
||||||
|
|
||||||
setCompleted((prev) => new Set(prev.add(initialResponse.messageId)));
|
setCompleted((prev) => new Set(prev.add(initialResponse.messageId)));
|
||||||
|
|
||||||
const conversationId =
|
const conversationId =
|
||||||
|
|
@ -595,7 +582,6 @@ export default function useEventHandlers({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Error:', data);
|
|
||||||
const errorResponse = tMessageSchema.parse({
|
const errorResponse = tMessageSchema.parse({
|
||||||
...data,
|
...data,
|
||||||
error: true,
|
error: true,
|
||||||
|
|
@ -619,11 +605,28 @@ export default function useEventHandlers({
|
||||||
const abortConversation = useCallback(
|
const abortConversation = useCallback(
|
||||||
async (conversationId = '', submission: EventSubmission, messages?: TMessage[]) => {
|
async (conversationId = '', submission: EventSubmission, messages?: TMessage[]) => {
|
||||||
const runAbortKey = `${conversationId}:${messages?.[messages.length - 1]?.messageId ?? ''}`;
|
const runAbortKey = `${conversationId}:${messages?.[messages.length - 1]?.messageId ?? ''}`;
|
||||||
console.log({ conversationId, submission, messages, runAbortKey });
|
|
||||||
const { endpoint: _endpoint, endpointType } =
|
const { endpoint: _endpoint, endpointType } =
|
||||||
(submission.conversation as TConversation | null) ?? {};
|
(submission.conversation as TConversation | null) ?? {};
|
||||||
const endpoint = endpointType ?? _endpoint;
|
const endpoint = endpointType ?? _endpoint;
|
||||||
if (!isAssistantsEndpoint(endpoint)) {
|
if (
|
||||||
|
!isAssistantsEndpoint(endpoint) &&
|
||||||
|
messages?.[messages.length - 1] != null &&
|
||||||
|
messages[messages.length - 2] != null
|
||||||
|
) {
|
||||||
|
const requestMessage = messages[messages.length - 2];
|
||||||
|
const responseMessage = messages[messages.length - 1];
|
||||||
|
finalHandler(
|
||||||
|
{
|
||||||
|
conversation: {
|
||||||
|
conversationId,
|
||||||
|
},
|
||||||
|
requestMessage,
|
||||||
|
responseMessage,
|
||||||
|
},
|
||||||
|
submission,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (!isAssistantsEndpoint(endpoint)) {
|
||||||
if (newConversation) {
|
if (newConversation) {
|
||||||
newConversation({
|
newConversation({
|
||||||
template: { conversationId: conversationId || v4() },
|
template: { conversationId: conversationId || v4() },
|
||||||
|
|
@ -651,7 +654,6 @@ export default function useEventHandlers({
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
if (contentType != null && contentType.includes('application/json')) {
|
if (contentType != null && contentType.includes('application/json')) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(`[aborted] RESPONSE STATUS: ${response.status}`, data);
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -662,16 +664,6 @@ export default function useEventHandlers({
|
||||||
cancelHandler(data, submission);
|
cancelHandler(data, submission);
|
||||||
}
|
}
|
||||||
} else if (response.status === 204 || response.status === 200) {
|
} else if (response.status === 204 || response.status === 200) {
|
||||||
const responseMessage = {
|
|
||||||
...submission.initialResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
requestMessage: submission.userMessage,
|
|
||||||
responseMessage: responseMessage,
|
|
||||||
conversation: submission.conversation,
|
|
||||||
};
|
|
||||||
console.log(`[aborted] RESPONSE STATUS: ${response.status}`, data);
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -682,8 +674,6 @@ export default function useEventHandlers({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cancelling request');
|
|
||||||
console.error(error);
|
|
||||||
const errorResponse = createErrorMessage({
|
const errorResponse = createErrorMessage({
|
||||||
getMessages,
|
getMessages,
|
||||||
submission,
|
submission,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { SSE } from 'sse.js';
|
import { SSE } from 'sse.js';
|
||||||
import { useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
request,
|
request,
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -12,12 +13,18 @@ import {
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { EventSubmission, TMessage, TPayload, TSubmission } from 'librechat-data-provider';
|
import type {
|
||||||
|
EventSubmission,
|
||||||
|
TConversation,
|
||||||
|
TMessage,
|
||||||
|
TPayload,
|
||||||
|
TSubmission,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
import type { EventHandlerParams } from './useEventHandlers';
|
import type { EventHandlerParams } from './useEventHandlers';
|
||||||
import type { TResData } from '~/common';
|
import type { TResData } from '~/common';
|
||||||
import { useGenTitleMutation, useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
import { useGenTitleMutation, useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||||
|
import useEventHandlers, { getConvoTitle } from './useEventHandlers';
|
||||||
import { useAuthContext } from '~/hooks/AuthContext';
|
import { useAuthContext } from '~/hooks/AuthContext';
|
||||||
import useEventHandlers from './useEventHandlers';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const clearDraft = (conversationId?: string | null) => {
|
const clearDraft = (conversationId?: string | null) => {
|
||||||
|
|
@ -46,6 +53,7 @@ export default function useSSE(
|
||||||
isAddedRequest = false,
|
isAddedRequest = false,
|
||||||
runIndex = 0,
|
runIndex = 0,
|
||||||
) {
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const genTitle = useGenTitleMutation();
|
const genTitle = useGenTitleMutation();
|
||||||
const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex));
|
const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex));
|
||||||
|
|
||||||
|
|
@ -99,6 +107,30 @@ export default function useSSE(
|
||||||
let { userMessage } = submission;
|
let { userMessage } = submission;
|
||||||
|
|
||||||
const payloadData = createPayload(submission);
|
const payloadData = createPayload(submission);
|
||||||
|
/**
|
||||||
|
* Helps clear text immediately on submission instead of
|
||||||
|
* restoring draft, which gets deleted on generation end
|
||||||
|
* */
|
||||||
|
const parentId = submission?.isRegenerate
|
||||||
|
? userMessage.overrideParentMessageId
|
||||||
|
: userMessage.parentMessageId;
|
||||||
|
setConversation?.((prev: TConversation | null) => {
|
||||||
|
if (!prev) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const title =
|
||||||
|
getConvoTitle({
|
||||||
|
parentId,
|
||||||
|
queryClient,
|
||||||
|
currentTitle: prev?.title,
|
||||||
|
conversationId: prev?.conversationId,
|
||||||
|
}) ?? '';
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
title,
|
||||||
|
conversationId: Constants.PENDING_CONVO as string,
|
||||||
|
};
|
||||||
|
});
|
||||||
let { payload } = payloadData;
|
let { payload } = payloadData;
|
||||||
if (isAssistantsEndpoint(payload.endpoint) || isAgentsEndpoint(payload.endpoint)) {
|
if (isAssistantsEndpoint(payload.endpoint) || isAgentsEndpoint(payload.endpoint)) {
|
||||||
payload = removeNullishValues(payload) as TPayload;
|
payload = removeNullishValues(payload) as TPayload;
|
||||||
|
|
@ -250,5 +282,6 @@ export default function useSSE(
|
||||||
sse.dispatchEvent(e);
|
sse.dispatchEvent(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [submission]);
|
}, [submission]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,6 @@
|
||||||
"com_nav_archive_created_at": "Date Archived",
|
"com_nav_archive_created_at": "Date Archived",
|
||||||
"com_nav_archive_name": "Name",
|
"com_nav_archive_name": "Name",
|
||||||
"com_nav_archived_chats": "Archived chats",
|
"com_nav_archived_chats": "Archived chats",
|
||||||
"com_nav_archived_chats_empty": "You have no archived conversations.",
|
|
||||||
"com_nav_at_command": "@-Command",
|
"com_nav_at_command": "@-Command",
|
||||||
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
||||||
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
||||||
|
|
@ -390,7 +389,6 @@
|
||||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
"com_nav_maximize_chat_space": "Maximize chat space",
|
||||||
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||||
"com_nav_my_files": "My Files",
|
"com_nav_my_files": "My Files",
|
||||||
"com_nav_no_search_results": "No search results found",
|
|
||||||
"com_nav_not_supported": "Not Supported",
|
"com_nav_not_supported": "Not Supported",
|
||||||
"com_nav_open_sidebar": "Open sidebar",
|
"com_nav_open_sidebar": "Open sidebar",
|
||||||
"com_nav_playback_rate": "Audio Playback Rate",
|
"com_nav_playback_rate": "Audio Playback Rate",
|
||||||
|
|
@ -486,8 +484,10 @@
|
||||||
"com_ui_analyzing": "Analyzing",
|
"com_ui_analyzing": "Analyzing",
|
||||||
"com_ui_analyzing_finished": "Finished analyzing",
|
"com_ui_analyzing_finished": "Finished analyzing",
|
||||||
"com_ui_api_key": "API Key",
|
"com_ui_api_key": "API Key",
|
||||||
|
"com_ui_convo_delete_error": "Failed to delete conversation",
|
||||||
"com_ui_archive": "Archive",
|
"com_ui_archive": "Archive",
|
||||||
"com_ui_archive_error": "Failed to archive conversation",
|
"com_ui_archive_error": "Failed to archive conversation",
|
||||||
|
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
||||||
"com_ui_artifact_click": "Click to open",
|
"com_ui_artifact_click": "Click to open",
|
||||||
"com_ui_artifacts": "Artifacts",
|
"com_ui_artifacts": "Artifacts",
|
||||||
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
|
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
|
||||||
|
|
@ -664,6 +664,10 @@
|
||||||
"com_ui_generating": "Generating...",
|
"com_ui_generating": "Generating...",
|
||||||
"com_ui_global_group": "something needs to go here. was empty",
|
"com_ui_global_group": "something needs to go here. was empty",
|
||||||
"com_ui_go_back": "Go back",
|
"com_ui_go_back": "Go back",
|
||||||
|
"com_ui_untitled": "Untitled",
|
||||||
|
"com_ui_new_conversation_title": "New Conversation Title",
|
||||||
|
"com_ui_rename_conversation": "Rename Conversation",
|
||||||
|
"com_ui_rename_failed": "Failed to rename conversation",
|
||||||
"com_ui_go_to_conversation": "Go to conversation",
|
"com_ui_go_to_conversation": "Go to conversation",
|
||||||
"com_ui_good_afternoon": "Good afternoon",
|
"com_ui_good_afternoon": "Good afternoon",
|
||||||
"com_ui_good_evening": "Good evening",
|
"com_ui_good_evening": "Good evening",
|
||||||
|
|
@ -712,9 +716,9 @@
|
||||||
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
|
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
|
||||||
"com_ui_no_category": "No category",
|
"com_ui_no_category": "No category",
|
||||||
"com_ui_no_changes": "No changes to update",
|
"com_ui_no_changes": "No changes to update",
|
||||||
"com_ui_no_data": "something needs to go here. was empty",
|
"com_ui_no_data": "No data",
|
||||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
"com_ui_no_valid_items": "No valid items",
|
||||||
"com_ui_none": "None",
|
"com_ui_none": "None",
|
||||||
"com_ui_not_used": "Not Used",
|
"com_ui_not_used": "Not Used",
|
||||||
"com_ui_nothing_found": "Nothing found",
|
"com_ui_nothing_found": "Nothing found",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||||
|
|
@ -38,6 +38,11 @@ export default function ChatRoute() {
|
||||||
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
|
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
|
||||||
const { newConversation } = useNewConvo();
|
const { newConversation } = useNewConvo();
|
||||||
|
|
||||||
|
// Reset the guard flag whenever conversationId changes
|
||||||
|
useEffect(() => {
|
||||||
|
hasSetConversation.current = false;
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
const modelsQuery = useGetModelsQuery({
|
const modelsQuery = useGetModelsQuery({
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
|
|
@ -122,6 +127,7 @@ export default function ChatRoute() {
|
||||||
endpointsQuery.data,
|
endpointsQuery.data,
|
||||||
modelsQuery.data,
|
modelsQuery.data,
|
||||||
assistantListMap,
|
assistantListMap,
|
||||||
|
conversationId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
|
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,73 @@
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import type { FetchNextPageOptions } from '@tanstack/react-query';
|
||||||
|
import { useToastContext, useSearchContext, useFileMapContext } from '~/Providers';
|
||||||
import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages';
|
import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages';
|
||||||
import SearchMessage from '~/components/Chat/Messages/SearchMessage';
|
import SearchMessage from '~/components/Chat/Messages/SearchMessage';
|
||||||
import { useSearchContext, useFileMapContext } from '~/Providers';
|
|
||||||
import { useNavScrolling, useLocalize } from '~/hooks';
|
import { useNavScrolling, useLocalize } from '~/hooks';
|
||||||
|
import { Spinner } from '~/components';
|
||||||
import { buildTree } from '~/utils';
|
import { buildTree } from '~/utils';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const fileMap = useFileMapContext();
|
const fileMap = useFileMapContext();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const { searchQuery, searchQueryRes } = useSearchContext();
|
const { searchQuery, searchQueryRes } = useSearchContext();
|
||||||
|
const isSearchTyping = useRecoilValue(store.isSearchTyping);
|
||||||
|
|
||||||
const { containerRef } = useNavScrolling({
|
const { containerRef } = useNavScrolling({
|
||||||
|
nextCursor: searchQueryRes?.data?.pages[searchQueryRes.data.pages.length - 1]?.nextCursor,
|
||||||
setShowLoading: () => ({}),
|
setShowLoading: () => ({}),
|
||||||
hasNextPage: searchQueryRes?.hasNextPage,
|
fetchNextPage: searchQueryRes?.fetchNextPage
|
||||||
fetchNextPage: searchQueryRes?.fetchNextPage,
|
? (options?: FetchNextPageOptions) => searchQueryRes.fetchNextPage(options)
|
||||||
isFetchingNextPage: searchQueryRes?.isFetchingNextPage ?? false,
|
: undefined,
|
||||||
|
isFetchingNext: searchQueryRes?.isFetchingNextPage ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQueryRes?.error) {
|
||||||
|
showToast({ message: 'An error occurred during search', status: 'error' });
|
||||||
|
}
|
||||||
|
}, [searchQueryRes?.error, showToast]);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
const msgs = searchQueryRes?.data?.pages.flatMap((page) => page.messages) || [];
|
const msgs = searchQueryRes?.data?.pages.flatMap((page) => page.messages) || [];
|
||||||
const dataTree = buildTree({ messages: msgs, fileMap });
|
const dataTree = buildTree({ messages: msgs, fileMap });
|
||||||
return dataTree?.length === 0 ? null : dataTree ?? null;
|
return dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||||
}, [fileMap, searchQueryRes?.data?.pages]);
|
}, [fileMap, searchQueryRes?.data?.pages]);
|
||||||
|
|
||||||
if (!searchQuery || !searchQueryRes?.data) {
|
if (!searchQuery || !searchQueryRes?.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSearchTyping || searchQueryRes.isInitialLoading || searchQueryRes.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Spinner className="text-text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MinimalMessagesWrapper ref={containerRef} className="pt-4">
|
<MinimalMessagesWrapper ref={containerRef} className="relative flex h-full pt-4">
|
||||||
{(messages && messages.length == 0) || messages == null ? (
|
{(messages && messages.length == 0) || messages == null ? (
|
||||||
<div className="my-auto flex h-full w-full items-center justify-center gap-1 bg-white p-3 text-lg text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
{localize('com_ui_nothing_found')}
|
<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')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message) => <SearchMessage key={message.messageId} message={message} />)
|
<>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<SearchMessage key={msg.messageId} message={msg} />
|
||||||
|
))}
|
||||||
|
{searchQueryRes.isFetchingNextPage && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Spinner className="text-text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-[5%] bg-gradient-to-t from-gray-50 to-transparent dark:from-gray-800" />
|
<div className="absolute bottom-0 left-0 right-0 h-[5%] bg-gradient-to-t from-gray-50 to-transparent dark:from-gray-800" />
|
||||||
</MinimalMessagesWrapper>
|
</MinimalMessagesWrapper>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,14 @@ const isSearching = atom({
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSearchTyping = atom({
|
||||||
|
key: 'isSearchTyping',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
isSearchEnabled,
|
isSearchEnabled,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isSearching,
|
isSearching,
|
||||||
|
isSearchTyping,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import type { TConversation, ConversationData } from 'librechat-data-provider';
|
import type { TConversation, InfiniteData } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
dateKeys,
|
dateKeys,
|
||||||
addConversation,
|
storeEndpointSettings,
|
||||||
updateConvoFields,
|
addConversationToInfinitePages,
|
||||||
updateConversation,
|
updateInfiniteConvoPage,
|
||||||
deleteConversation,
|
findConversationInInfinite,
|
||||||
findPageForConversation,
|
removeConvoFromInfinitePages,
|
||||||
groupConversationsByDate,
|
groupConversationsByDate,
|
||||||
|
updateConvoFieldsInfinite,
|
||||||
|
addConvoToAllQueries,
|
||||||
|
updateConvoInAllQueries,
|
||||||
|
removeConvoFromAllQueries,
|
||||||
|
addConversationToAllConversationsQueries,
|
||||||
} from './convos';
|
} from './convos';
|
||||||
import { convoData } from './convos.fakeData';
|
import { convoData } from './convos.fakeData';
|
||||||
import { normalizeData } from './collection';
|
import { normalizeData } from './collection';
|
||||||
|
|
@ -26,9 +31,9 @@ describe('Conversation Utilities', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
{ conversationId: '1', updatedAt: '2023-04-01T12:00:00Z' },
|
{ conversationId: '1', updatedAt: '2023-04-01T12:00:00Z' },
|
||||||
{ conversationId: '2', updatedAt: new Date().toISOString() },
|
{ conversationId: '2', updatedAt: new Date().toISOString() },
|
||||||
{ conversationId: '3', updatedAt: new Date(Date.now() - 86400000).toISOString() }, // 86400 seconds ago = yesterday
|
{ conversationId: '3', updatedAt: new Date(Date.now() - 86400000).toISOString() },
|
||||||
{ conversationId: '4', updatedAt: new Date(Date.now() - 86400000 * 2).toISOString() }, // 2 days ago (previous 7 days)
|
{ conversationId: '4', updatedAt: new Date(Date.now() - 86400000 * 2).toISOString() },
|
||||||
{ conversationId: '5', updatedAt: new Date(Date.now() - 86400000 * 8).toISOString() }, // 8 days ago (previous 30 days)
|
{ conversationId: '5', updatedAt: new Date(Date.now() - 86400000 * 8).toISOString() },
|
||||||
];
|
];
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
expect(grouped[0][0]).toBe(dateKeys.today);
|
expect(grouped[0][0]).toBe(dateKeys.today);
|
||||||
|
|
@ -43,84 +48,20 @@ describe('Conversation Utilities', () => {
|
||||||
expect(grouped[4][1]).toHaveLength(1);
|
expect(grouped[4][1]).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('groups conversations correctly across multiple years', () => {
|
|
||||||
const fixedDate = new Date('2023-07-15T12:00:00Z');
|
|
||||||
const conversations = [
|
|
||||||
{ conversationId: '1', updatedAt: '2023-07-15T10:00:00Z' }, // Today
|
|
||||||
{ conversationId: '2', updatedAt: '2023-07-14T12:00:00Z' }, // Yesterday
|
|
||||||
{ conversationId: '3', updatedAt: '2023-07-08T12:00:00Z' }, // This week
|
|
||||||
{ conversationId: '4', updatedAt: '2023-07-01T12:00:00Z' }, // This month (within last 30 days)
|
|
||||||
{ conversationId: '5', updatedAt: '2023-06-01T12:00:00Z' }, // Last month
|
|
||||||
{ conversationId: '6', updatedAt: '2023-01-01T12:00:00Z' }, // This year, January
|
|
||||||
{ conversationId: '7', updatedAt: '2022-12-01T12:00:00Z' }, // Last year, December
|
|
||||||
{ conversationId: '8', updatedAt: '2022-06-01T12:00:00Z' }, // Last year, June
|
|
||||||
{ conversationId: '9', updatedAt: '2021-12-01T12:00:00Z' }, // Two years ago
|
|
||||||
{ conversationId: '10', updatedAt: '2020-06-01T12:00:00Z' }, // Three years ago
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mock Date.now
|
|
||||||
const originalDateNow = Date.now;
|
|
||||||
Date.now = jest.fn(() => fixedDate.getTime());
|
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
|
||||||
|
|
||||||
// Restore Date.now
|
|
||||||
Date.now = originalDateNow;
|
|
||||||
|
|
||||||
const expectedGroups = [
|
|
||||||
dateKeys.today,
|
|
||||||
dateKeys.yesterday,
|
|
||||||
dateKeys.previous7Days,
|
|
||||||
dateKeys.previous30Days,
|
|
||||||
dateKeys.june,
|
|
||||||
dateKeys.january,
|
|
||||||
' 2022',
|
|
||||||
' 2021',
|
|
||||||
' 2020',
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(grouped.map(([key]) => key)).toEqual(expectedGroups);
|
|
||||||
|
|
||||||
// Helper function to safely get group length
|
|
||||||
const getGroupLength = (key: string) => grouped.find(([k]) => k === key)?.[1]?.length ?? 0;
|
|
||||||
|
|
||||||
// Check specific group contents
|
|
||||||
expect(getGroupLength(dateKeys.today)).toBe(1);
|
|
||||||
expect(getGroupLength(dateKeys.yesterday)).toBe(1);
|
|
||||||
expect(getGroupLength(dateKeys.previous7Days)).toBe(1);
|
|
||||||
expect(getGroupLength(dateKeys.previous30Days)).toBe(1);
|
|
||||||
expect(getGroupLength(dateKeys.june)).toBe(1);
|
|
||||||
expect(getGroupLength(dateKeys.january)).toBe(1);
|
|
||||||
expect(getGroupLength(' 2022')).toBe(2); // December and June 2022
|
|
||||||
expect(getGroupLength(' 2021')).toBe(1);
|
|
||||||
expect(getGroupLength(' 2020')).toBe(1);
|
|
||||||
|
|
||||||
// Check that all conversations are accounted for
|
|
||||||
const totalGroupedConversations = grouped.reduce(
|
|
||||||
(total, [, convos]) => total + convos.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
expect(totalGroupedConversations).toBe(conversations.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty array for no conversations', () => {
|
|
||||||
expect(groupConversationsByDate([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips conversations with duplicate conversationIds', () => {
|
it('skips conversations with duplicate conversationIds', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
{ conversationId: '1', updatedAt: '2023-12-01T12:00:00Z' }, // " 2023"
|
{ conversationId: '1', updatedAt: '2023-12-01T12:00:00Z' },
|
||||||
{ conversationId: '2', updatedAt: '2023-11-25T12:00:00Z' }, // " 2023"
|
{ conversationId: '2', updatedAt: '2023-11-25T12:00:00Z' },
|
||||||
{ conversationId: '1', updatedAt: '2023-11-20T12:00:00Z' }, // Should be skipped because of duplicate ID
|
{ conversationId: '1', updatedAt: '2023-11-20T12:00:00Z' },
|
||||||
{ conversationId: '3', updatedAt: '2022-12-01T12:00:00Z' }, // " 2022"
|
{ conversationId: '3', updatedAt: '2022-12-01T12:00:00Z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
|
|
||||||
expect(grouped).toEqual(
|
expect(grouped).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.arrayContaining([' 2023', expect.arrayContaining(conversations.slice(0, 2))]),
|
[' 2023', expect.arrayContaining([conversations[0], conversations[1]])],
|
||||||
expect.arrayContaining([' 2022', expect.arrayContaining([conversations[3]])]),
|
[' 2022', expect.arrayContaining([conversations[3]])],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -132,22 +73,25 @@ describe('Conversation Utilities', () => {
|
||||||
|
|
||||||
it('sorts conversations by month correctly', () => {
|
it('sorts conversations by month correctly', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' }, // January 2023
|
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' },
|
||||||
{ conversationId: '2', updatedAt: '2023-12-01T12:00:00Z' }, // December 2023
|
{ conversationId: '2', updatedAt: '2023-12-01T12:00:00Z' },
|
||||||
{ conversationId: '3', updatedAt: '2023-02-01T12:00:00Z' }, // February 2023
|
{ conversationId: '3', updatedAt: '2023-02-01T12:00:00Z' },
|
||||||
{ conversationId: '4', updatedAt: '2023-11-01T12:00:00Z' }, // November 2023
|
{ conversationId: '4', updatedAt: '2023-11-01T12:00:00Z' },
|
||||||
{ conversationId: '5', updatedAt: '2022-12-01T12:00:00Z' }, // December 2022
|
{ conversationId: '5', updatedAt: '2022-12-01T12:00:00Z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
|
|
||||||
// Check if the years are in the correct order (most recent first)
|
// Now expect grouping by year for 2023 and 2022
|
||||||
expect(grouped.map(([key]) => key)).toEqual([' 2023', ' 2022']);
|
const expectedGroups = [' 2023', ' 2022'];
|
||||||
|
expect(grouped.map(([key]) => key)).toEqual(expectedGroups);
|
||||||
|
|
||||||
// Check if conversations within 2023 are sorted correctly by month
|
// Check if conversations within 2023 are sorted correctly by updatedAt descending
|
||||||
const conversationsIn2023 = grouped[0][1];
|
const conversationsIn2023 = grouped[0][1];
|
||||||
const monthsIn2023 = conversationsIn2023.map((c) => new Date(c.updatedAt).getMonth());
|
const sorted = [...conversationsIn2023].sort(
|
||||||
expect(monthsIn2023).toEqual([11, 10, 1, 0]); // December (11), November (10), February (1), January (0)
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
expect(conversationsIn2023).toEqual(sorted);
|
||||||
|
|
||||||
// Check if the conversation from 2022 is in its own group
|
// Check if the conversation from 2022 is in its own group
|
||||||
expect(grouped[1][1].length).toBe(1);
|
expect(grouped[1][1].length).toBe(1);
|
||||||
|
|
@ -156,19 +100,19 @@ describe('Conversation Utilities', () => {
|
||||||
|
|
||||||
it('handles conversations from multiple years correctly', () => {
|
it('handles conversations from multiple years correctly', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' }, // January 2023
|
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' },
|
||||||
{ conversationId: '2', updatedAt: '2022-12-01T12:00:00Z' }, // December 2022
|
{ conversationId: '2', updatedAt: '2022-12-01T12:00:00Z' },
|
||||||
{ conversationId: '3', updatedAt: '2021-06-01T12:00:00Z' }, // June 2021
|
{ conversationId: '3', updatedAt: '2021-06-01T12:00:00Z' },
|
||||||
{ conversationId: '4', updatedAt: '2023-06-01T12:00:00Z' }, // June 2023
|
{ conversationId: '4', updatedAt: '2023-06-01T12:00:00Z' },
|
||||||
{ conversationId: '5', updatedAt: '2021-12-01T12:00:00Z' }, // December 2021
|
{ conversationId: '5', updatedAt: '2021-12-01T12:00:00Z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
|
|
||||||
expect(grouped.map(([key]) => key)).toEqual([' 2023', ' 2022', ' 2021']);
|
expect(grouped.map(([key]) => key)).toEqual([' 2023', ' 2022', ' 2021']);
|
||||||
expect(grouped[0][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([5, 0]); // June, January
|
expect(grouped[0][1].map((c) => new Date(c.updatedAt).getFullYear())).toEqual([2023, 2023]);
|
||||||
expect(grouped[1][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([11]); // December
|
expect(grouped[1][1].map((c) => new Date(c.updatedAt).getFullYear())).toEqual([2022]);
|
||||||
expect(grouped[2][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([11, 5]); // December, June
|
expect(grouped[2][1].map((c) => new Date(c.updatedAt).getFullYear())).toEqual([2021, 2021]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles conversations from the same month correctly', () => {
|
it('handles conversations from the same month correctly', () => {
|
||||||
|
|
@ -185,28 +129,6 @@ describe('Conversation Utilities', () => {
|
||||||
expect(grouped[0][1].map((c) => c.conversationId)).toEqual(['3', '2', '1']);
|
expect(grouped[0][1].map((c) => c.conversationId)).toEqual(['3', '2', '1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles conversations from today, yesterday, and previous days correctly', () => {
|
|
||||||
const today = new Date();
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const twoDaysAgo = new Date(today);
|
|
||||||
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
|
||||||
|
|
||||||
const conversations = [
|
|
||||||
{ conversationId: '1', updatedAt: today.toISOString() },
|
|
||||||
{ conversationId: '2', updatedAt: yesterday.toISOString() },
|
|
||||||
{ conversationId: '3', updatedAt: twoDaysAgo.toISOString() },
|
|
||||||
];
|
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
|
||||||
|
|
||||||
expect(grouped.map(([key]) => key)).toEqual([
|
|
||||||
dateKeys.today,
|
|
||||||
dateKeys.yesterday,
|
|
||||||
dateKeys.previous7Days,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles conversations with null or undefined updatedAt correctly', () => {
|
it('handles conversations with null or undefined updatedAt correctly', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
{ conversationId: '1', updatedAt: '2023-06-01T12:00:00Z' },
|
{ conversationId: '1', updatedAt: '2023-06-01T12:00:00Z' },
|
||||||
|
|
@ -216,17 +138,11 @@ describe('Conversation Utilities', () => {
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
|
|
||||||
expect(grouped.length).toBe(2); // One group for 2023 and one for today (null/undefined dates)
|
expect(grouped.length).toBe(2);
|
||||||
expect(grouped[0][0]).toBe(dateKeys.today);
|
expect(grouped[0][0]).toBe(dateKeys.today);
|
||||||
expect(grouped[0][1].length).toBe(2); // Two conversations with null/undefined dates
|
expect(grouped[0][1].length).toBe(2);
|
||||||
expect(grouped[1][0]).toBe(' 2023');
|
expect(grouped[1][0]).toBe(' 2023');
|
||||||
expect(grouped[1][1].length).toBe(1); // One conversation from 2023
|
expect(grouped[1][1].length).toBe(1);
|
||||||
});
|
|
||||||
|
|
||||||
it('handles an empty array of conversations', () => {
|
|
||||||
const grouped = groupConversationsByDate([]);
|
|
||||||
|
|
||||||
expect(grouped).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('correctly groups and sorts conversations for every month of the year', () => {
|
it('correctly groups and sorts conversations for every month of the year', () => {
|
||||||
|
|
@ -259,205 +175,22 @@ describe('Conversation Utilities', () => {
|
||||||
|
|
||||||
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
const grouped = groupConversationsByDate(conversations as TConversation[]);
|
||||||
|
|
||||||
// Check that we have two year groups
|
// All 2023 conversations should be in a single group
|
||||||
expect(grouped.length).toBe(2);
|
const group2023 = grouped.find(([key]) => key === ' 2023');
|
||||||
|
|
||||||
// Check 2023 months
|
|
||||||
const group2023 = grouped.find(([key]) => key === ' 2023') ?? [];
|
|
||||||
expect(group2023).toBeDefined();
|
expect(group2023).toBeDefined();
|
||||||
const grouped2023 = group2023[1];
|
expect(group2023![1].length).toBe(12);
|
||||||
expect(grouped2023?.length).toBe(12);
|
|
||||||
expect(grouped2023?.map((c) => new Date(c.updatedAt).getMonth())).toEqual([
|
|
||||||
11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check 2022 months
|
// All 2022 conversations should be in a single group
|
||||||
const group2022 = grouped.find(([key]) => key === ' 2022') ?? [];
|
const group2022 = grouped.find(([key]) => key === ' 2022');
|
||||||
expect(group2022).toBeDefined();
|
expect(group2022).toBeDefined();
|
||||||
const grouped2022 = group2022[1];
|
expect(group2022![1].length).toBe(12);
|
||||||
expect(grouped2022?.length).toBe(12);
|
|
||||||
expect(grouped2022?.map((c) => new Date(c.updatedAt).getMonth())).toEqual([
|
|
||||||
11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check that all conversations are accounted for
|
// Check that all conversations are accounted for
|
||||||
const totalGroupedConversations =
|
const totalGroupedConversations = grouped.reduce(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
(total, [_, convos]) => total + convos.length,
|
||||||
grouped.reduce((total, [_, convos]) => total + convos.length, 0);
|
0,
|
||||||
|
);
|
||||||
expect(totalGroupedConversations).toBe(conversations.length);
|
expect(totalGroupedConversations).toBe(conversations.length);
|
||||||
|
|
||||||
// Check that the years are in the correct order
|
|
||||||
const yearOrder = grouped.map(([key]) => key);
|
|
||||||
expect(yearOrder).toEqual([' 2023', ' 2022']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addConversation', () => {
|
|
||||||
it('adds a new conversation to the top of the list', () => {
|
|
||||||
const data = { pages: [{ conversations: [] }] };
|
|
||||||
const newConversation = {
|
|
||||||
conversationId: Constants.NEW_CONVO,
|
|
||||||
updatedAt: '2023-04-02T12:00:00Z',
|
|
||||||
};
|
|
||||||
const newData = addConversation(
|
|
||||||
data as unknown as ConversationData,
|
|
||||||
newConversation as TConversation,
|
|
||||||
);
|
|
||||||
expect(newData.pages[0].conversations).toHaveLength(1);
|
|
||||||
expect(newData.pages[0].conversations[0].conversationId).toBe(Constants.NEW_CONVO);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateConversation', () => {
|
|
||||||
it('updates an existing conversation and moves it to the top', () => {
|
|
||||||
const initialData = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
conversations: [
|
|
||||||
{ conversationId: '1', updatedAt: '2023-04-01T12:00:00Z' },
|
|
||||||
{ conversationId: '2', updatedAt: '2023-04-01T13:00:00Z' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const updatedConversation = { conversationId: '1', updatedAt: '2023-04-02T12:00:00Z' };
|
|
||||||
const newData = updateConversation(
|
|
||||||
initialData as unknown as ConversationData,
|
|
||||||
updatedConversation as TConversation,
|
|
||||||
);
|
|
||||||
expect(newData.pages[0].conversations).toHaveLength(2);
|
|
||||||
expect(newData.pages[0].conversations[0].conversationId).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateConvoFields', () => {
|
|
||||||
it('updates specific fields of a conversation', () => {
|
|
||||||
const initialData = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
conversations: [
|
|
||||||
{ conversationId: '1', title: 'Old Title', updatedAt: '2023-04-01T12:00:00Z' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const updatedFields = { conversationId: '1', title: 'New Title' };
|
|
||||||
const newData = updateConvoFields(
|
|
||||||
initialData as ConversationData,
|
|
||||||
updatedFields as TConversation,
|
|
||||||
);
|
|
||||||
expect(newData.pages[0].conversations[0].title).toBe('New Title');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteConversation', () => {
|
|
||||||
it('removes a conversation by id', () => {
|
|
||||||
const initialData = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
conversations: [
|
|
||||||
{ conversationId: '1', updatedAt: '2023-04-01T12:00:00Z' },
|
|
||||||
{ conversationId: '2', updatedAt: '2023-04-01T13:00:00Z' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const newData = deleteConversation(initialData as ConversationData, '1');
|
|
||||||
expect(newData.pages[0].conversations).toHaveLength(1);
|
|
||||||
expect(newData.pages[0].conversations[0].conversationId).not.toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findPageForConversation', () => {
|
|
||||||
it('finds the correct page and index for a given conversation', () => {
|
|
||||||
const data = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
conversations: [
|
|
||||||
{ conversationId: '1', updatedAt: '2023-04-01T12:00:00Z' },
|
|
||||||
{ conversationId: '2', updatedAt: '2023-04-02T13:00:00Z' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { pageIndex, index } = findPageForConversation(data as ConversationData, {
|
|
||||||
conversationId: '2',
|
|
||||||
});
|
|
||||||
expect(pageIndex).toBe(0);
|
|
||||||
expect(index).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Conversation Utilities with Fake Data', () => {
|
|
||||||
describe('groupConversationsByDate', () => {
|
|
||||||
it('correctly groups conversations from fake data by date', () => {
|
|
||||||
const { pages } = convoData;
|
|
||||||
const allConversations = pages.flatMap((p) => p.conversations);
|
|
||||||
const grouped = groupConversationsByDate(allConversations);
|
|
||||||
|
|
||||||
expect(grouped).toHaveLength(1);
|
|
||||||
expect(grouped[0][1]).toBeInstanceOf(Array);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addConversation', () => {
|
|
||||||
it('adds a new conversation to the existing fake data', () => {
|
|
||||||
const newConversation = {
|
|
||||||
conversationId: Constants.NEW_CONVO,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
} as TConversation;
|
|
||||||
const initialLength = convoData.pages[0].conversations.length;
|
|
||||||
const newData = addConversation(convoData, newConversation);
|
|
||||||
expect(newData.pages[0].conversations.length).toBe(initialLength + 1);
|
|
||||||
expect(newData.pages[0].conversations[0].conversationId).toBe(Constants.NEW_CONVO);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateConversation', () => {
|
|
||||||
it('updates an existing conversation within fake data', () => {
|
|
||||||
const updatedConversation = {
|
|
||||||
...convoData.pages[0].conversations[0],
|
|
||||||
title: 'Updated Title',
|
|
||||||
};
|
|
||||||
const newData = updateConversation(convoData, updatedConversation);
|
|
||||||
expect(newData.pages[0].conversations[0].title).toBe('Updated Title');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateConvoFields', () => {
|
|
||||||
it('updates specific fields of a conversation in fake data', () => {
|
|
||||||
const updatedFields = {
|
|
||||||
conversationId: convoData.pages[0].conversations[0].conversationId,
|
|
||||||
title: 'Partially Updated Title',
|
|
||||||
};
|
|
||||||
const newData = updateConvoFields(convoData, updatedFields as TConversation);
|
|
||||||
const updatedConversation = newData.pages[0].conversations.find(
|
|
||||||
(c) => c.conversationId === updatedFields.conversationId,
|
|
||||||
);
|
|
||||||
expect(updatedConversation?.title).toBe('Partially Updated Title');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteConversation', () => {
|
|
||||||
it('removes a conversation by id from fake data', () => {
|
|
||||||
const conversationIdToDelete = convoData.pages[0].conversations[0].conversationId as string;
|
|
||||||
const newData = deleteConversation(convoData, conversationIdToDelete);
|
|
||||||
const deletedConvoExists = newData.pages[0].conversations.some(
|
|
||||||
(c) => c.conversationId === conversationIdToDelete,
|
|
||||||
);
|
|
||||||
expect(deletedConvoExists).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findPageForConversation', () => {
|
|
||||||
it('finds the correct page and index for a given conversation in fake data', () => {
|
|
||||||
const targetConversation = convoData.pages[0].conversations[0];
|
|
||||||
const { pageIndex, index } = findPageForConversation(convoData, {
|
|
||||||
conversationId: targetConversation.conversationId as string,
|
|
||||||
});
|
|
||||||
expect(pageIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(index).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -627,4 +360,277 @@ describe('Conversation Utilities with Fake Data', () => {
|
||||||
expect(normalizedData.pageParams).toHaveLength(2);
|
expect(normalizedData.pageParams).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('InfiniteData helpers', () => {
|
||||||
|
const makeConversation = (id: string, updatedAt?: string) => ({
|
||||||
|
conversationId: id,
|
||||||
|
updatedAt: updatedAt || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makePage = (conversations: any[], nextCursor: string | null = null) => ({
|
||||||
|
conversations,
|
||||||
|
nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findConversationInInfinite', () => {
|
||||||
|
it('finds a conversation by id in InfiniteData', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [
|
||||||
|
makePage([makeConversation('1'), makeConversation('2')]),
|
||||||
|
makePage([makeConversation('3')]),
|
||||||
|
],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const found = findConversationInInfinite(data, '2');
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found?.conversationId).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if conversation not found', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
expect(findConversationInInfinite(data, 'notfound')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if data is undefined', () => {
|
||||||
|
expect(findConversationInInfinite(undefined, '1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateInfiniteConvoPage', () => {
|
||||||
|
it('updates a conversation in InfiniteData', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1', '2023-01-01T00:00:00Z'), makeConversation('2')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updater = (c: any) => ({ ...c, updatedAt: '2024-01-01T00:00:00Z' });
|
||||||
|
const updated = updateInfiniteConvoPage(data, '1', updater);
|
||||||
|
expect(updated?.pages[0].conversations[0].updatedAt).toBe('2024-01-01T00:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original data if conversation not found', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updater = (c: any) => ({ ...c, foo: 'bar' });
|
||||||
|
const updated = updateInfiniteConvoPage(data, 'notfound', updater);
|
||||||
|
expect(updated).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if data is undefined', () => {
|
||||||
|
expect(updateInfiniteConvoPage(undefined, '1', (c) => c)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addConversationToInfinitePages', () => {
|
||||||
|
it('adds a conversation to the first page', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const newConvo = makeConversation('new');
|
||||||
|
const updated = addConversationToInfinitePages(data, newConvo);
|
||||||
|
expect(updated.pages[0].conversations[0].conversationId).toBe('new');
|
||||||
|
expect(updated.pages[0].conversations[1].conversationId).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new InfiniteData if data is undefined', () => {
|
||||||
|
const newConvo = makeConversation('new');
|
||||||
|
const updated = addConversationToInfinitePages(undefined, newConvo);
|
||||||
|
expect(updated.pages[0].conversations[0].conversationId).toBe('new');
|
||||||
|
expect(updated.pageParams).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeConvoFromInfinitePages', () => {
|
||||||
|
it('removes a conversation by id', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [
|
||||||
|
makePage([makeConversation('1'), makeConversation('2')]),
|
||||||
|
makePage([makeConversation('3')]),
|
||||||
|
],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updated = removeConvoFromInfinitePages(data, '2');
|
||||||
|
expect(updated?.pages[0].conversations.map((c) => c.conversationId)).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes empty pages after deletion', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1')]), makePage([makeConversation('2')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updated = removeConvoFromInfinitePages(data, '2');
|
||||||
|
expect(updated?.pages.length).toBe(1);
|
||||||
|
expect(updated?.pages[0].conversations[0].conversationId).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original data if data is undefined', () => {
|
||||||
|
expect(removeConvoFromInfinitePages(undefined, '1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateConvoFieldsInfinite', () => {
|
||||||
|
it('updates fields and bumps to front if keepPosition is false', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [
|
||||||
|
makePage([makeConversation('1'), makeConversation('2')]),
|
||||||
|
makePage([makeConversation('3')]),
|
||||||
|
],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updated = updateConvoFieldsInfinite(
|
||||||
|
data,
|
||||||
|
{ conversationId: '2', title: 'new' },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(updated?.pages[0].conversations[0].conversationId).toBe('2');
|
||||||
|
expect(updated?.pages[0].conversations[0].title).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fields and keeps position if keepPosition is true', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1'), makeConversation('2')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updated = updateConvoFieldsInfinite(
|
||||||
|
data,
|
||||||
|
{ conversationId: '2', title: 'stay' },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(updated?.pages[0].conversations[1].title).toBe('stay');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original data if conversation not found', () => {
|
||||||
|
const data = {
|
||||||
|
pages: [makePage([makeConversation('1')])],
|
||||||
|
pageParams: [],
|
||||||
|
};
|
||||||
|
const updated = updateConvoFieldsInfinite(
|
||||||
|
data,
|
||||||
|
{ conversationId: 'notfound', title: 'x' },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(updated).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original data if data is undefined', () => {
|
||||||
|
expect(
|
||||||
|
updateConvoFieldsInfinite(undefined, { conversationId: '1', title: 'x' }, false),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeEndpointSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores model for endpoint', () => {
|
||||||
|
const conversation = {
|
||||||
|
conversationId: '1',
|
||||||
|
endpoint: 'openai',
|
||||||
|
model: 'gpt-3',
|
||||||
|
};
|
||||||
|
storeEndpointSettings(conversation as any);
|
||||||
|
const stored = JSON.parse(localStorage.getItem('lastModel') || '{}');
|
||||||
|
expect([undefined, 'gpt-3']).toContain(stored.openai);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores secondaryModel for gptPlugins endpoint', () => {
|
||||||
|
const conversation = {
|
||||||
|
conversationId: '1',
|
||||||
|
endpoint: 'gptPlugins',
|
||||||
|
model: 'gpt-4',
|
||||||
|
agentOptions: { model: 'plugin-model' },
|
||||||
|
};
|
||||||
|
storeEndpointSettings(conversation as any);
|
||||||
|
const stored = JSON.parse(localStorage.getItem('lastModel') || '{}');
|
||||||
|
expect([undefined, 'gpt-4']).toContain(stored.gptPlugins);
|
||||||
|
expect([undefined, 'plugin-model']).toContain(stored.secondaryModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if conversation is null', () => {
|
||||||
|
storeEndpointSettings(null);
|
||||||
|
expect(localStorage.getItem('lastModel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if endpoint is missing', () => {
|
||||||
|
storeEndpointSettings({ conversationId: '1', model: 'x' } as any);
|
||||||
|
expect(localStorage.getItem('lastModel')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QueryClient helpers', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
let convoA: TConversation;
|
||||||
|
let convoB: TConversation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient();
|
||||||
|
convoA = {
|
||||||
|
conversationId: 'a',
|
||||||
|
updatedAt: '2024-01-01T12:00:00Z',
|
||||||
|
createdAt: '2024-01-01T10:00:00Z',
|
||||||
|
endpoint: 'openai',
|
||||||
|
model: 'gpt-3',
|
||||||
|
title: 'Conversation A',
|
||||||
|
} as TConversation;
|
||||||
|
convoB = {
|
||||||
|
conversationId: 'b',
|
||||||
|
updatedAt: '2024-01-02T12:00:00Z',
|
||||||
|
endpoint: 'openai',
|
||||||
|
model: 'gpt-3',
|
||||||
|
} as TConversation;
|
||||||
|
queryClient.setQueryData(['allConversations'], {
|
||||||
|
pages: [{ conversations: [convoA], nextCursor: null }],
|
||||||
|
pageParams: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addConvoToAllQueries adds new on top if not present', () => {
|
||||||
|
addConvoToAllQueries(queryClient, convoB);
|
||||||
|
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||||
|
expect(data!.pages[0].conversations[0].conversationId).toBe('b');
|
||||||
|
expect(data!.pages[0].conversations.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addConvoToAllQueries does not duplicate', () => {
|
||||||
|
addConvoToAllQueries(queryClient, convoA);
|
||||||
|
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||||
|
expect(data!.pages[0].conversations.filter((c) => c.conversationId === 'a').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateConvoInAllQueries updates correct convo', () => {
|
||||||
|
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, model: 'gpt-4' }));
|
||||||
|
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||||
|
expect(data!.pages[0].conversations[0].model).toBe('gpt-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeConvoFromAllQueries deletes conversation', () => {
|
||||||
|
removeConvoFromAllQueries(queryClient, 'a');
|
||||||
|
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||||
|
expect(data!.pages.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addConversationToAllConversationsQueries works with multiple pages', () => {
|
||||||
|
queryClient.setQueryData(['allConversations', 'other'], {
|
||||||
|
pages: [{ conversations: [], nextCursor: null }],
|
||||||
|
pageParams: [],
|
||||||
|
});
|
||||||
|
addConversationToAllConversationsQueries(queryClient, convoB);
|
||||||
|
|
||||||
|
const mainData = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||||
|
const otherData = queryClient.getQueryData<InfiniteData<any>>([
|
||||||
|
'allConversations',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
expect(mainData!.pages[0].conversations[0].conversationId).toBe('b');
|
||||||
|
expect(otherData!.pages[0].conversations[0].conversationId).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,12 @@ import {
|
||||||
startOfYear,
|
startOfYear,
|
||||||
isWithinInterval,
|
isWithinInterval,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import type {
|
import { EModelEndpoint, LocalStorageKeys, QueryKeys } from 'librechat-data-provider';
|
||||||
TConversation,
|
import type { TConversation, GroupedConversations } from 'librechat-data-provider';
|
||||||
ConversationData,
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
GroupedConversations,
|
|
||||||
ConversationListResponse,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
|
|
||||||
import { addData, deleteData, updateData, findPage } from './collection';
|
|
||||||
import { InfiniteData } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
|
// Date group helpers
|
||||||
export const dateKeys = {
|
export const dateKeys = {
|
||||||
today: 'com_ui_date_today',
|
today: 'com_ui_date_today',
|
||||||
yesterday: 'com_ui_date_yesterday',
|
yesterday: 'com_ui_date_yesterday',
|
||||||
|
|
@ -73,11 +68,7 @@ const monthOrderMap = new Map([
|
||||||
['february', 1],
|
['february', 1],
|
||||||
['january', 0],
|
['january', 0],
|
||||||
]);
|
]);
|
||||||
|
const dateKeysReverse = Object.fromEntries(Object.entries(dateKeys).map(([k, v]) => [v, k]));
|
||||||
const dateKeysReverse = Object.fromEntries(
|
|
||||||
Object.entries(dateKeys).map(([key, value]) => [value, key]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateGroupsSet = new Set([
|
const dateGroupsSet = new Set([
|
||||||
dateKeys.today,
|
dateKeys.today,
|
||||||
dateKeys.yesterday,
|
dateKeys.yesterday,
|
||||||
|
|
@ -91,7 +82,6 @@ export const groupConversationsByDate = (
|
||||||
if (!Array.isArray(conversations)) {
|
if (!Array.isArray(conversations)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenConversationIds = new Set();
|
const seenConversationIds = new Set();
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
const now = new Date(Date.now());
|
const now = new Date(Date.now());
|
||||||
|
|
@ -108,7 +98,6 @@ export const groupConversationsByDate = (
|
||||||
} else {
|
} else {
|
||||||
date = now;
|
date = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupName = getGroupName(date);
|
const groupName = getGroupName(date);
|
||||||
if (!groups.has(groupName)) {
|
if (!groups.has(groupName)) {
|
||||||
groups.set(groupName, []);
|
groups.set(groupName, []);
|
||||||
|
|
@ -117,15 +106,12 @@ export const groupConversationsByDate = (
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedGroups = new Map();
|
const sortedGroups = new Map();
|
||||||
|
|
||||||
// Add date groups first
|
|
||||||
dateGroupsSet.forEach((group) => {
|
dateGroupsSet.forEach((group) => {
|
||||||
if (groups.has(group)) {
|
if (groups.has(group)) {
|
||||||
sortedGroups.set(group, groups.get(group));
|
sortedGroups.set(group, groups.get(group));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort and add year/month groups
|
|
||||||
const yearMonthGroups = Array.from(groups.keys())
|
const yearMonthGroups = Array.from(groups.keys())
|
||||||
.filter((group) => !dateGroupsSet.has(group))
|
.filter((group) => !dateGroupsSet.has(group))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -133,141 +119,285 @@ export const groupConversationsByDate = (
|
||||||
if (yearA !== yearB) {
|
if (yearA !== yearB) {
|
||||||
return yearB - yearA;
|
return yearB - yearA;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [monthA, monthB] = [dateKeysReverse[a], dateKeysReverse[b]];
|
const [monthA, monthB] = [dateKeysReverse[a], dateKeysReverse[b]];
|
||||||
const bOrder = monthOrderMap.get(monthB) ?? -1;
|
const bOrder = monthOrderMap.get(monthB) ?? -1,
|
||||||
const aOrder = monthOrderMap.get(monthA) ?? -1;
|
aOrder = monthOrderMap.get(monthA) ?? -1;
|
||||||
return bOrder - aOrder;
|
return bOrder - aOrder;
|
||||||
});
|
});
|
||||||
|
|
||||||
yearMonthGroups.forEach((group) => {
|
yearMonthGroups.forEach((group) => {
|
||||||
sortedGroups.set(group, groups.get(group));
|
sortedGroups.set(group, groups.get(group));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort conversations within each group
|
|
||||||
sortedGroups.forEach((conversations) => {
|
sortedGroups.forEach((conversations) => {
|
||||||
conversations.sort(
|
conversations.sort(
|
||||||
(a: TConversation, b: TConversation) =>
|
(a: TConversation, b: TConversation) =>
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(sortedGroups, ([key, value]) => [key, value]);
|
return Array.from(sortedGroups, ([key, value]) => [key, value]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addConversation = (
|
export type ConversationCursorData = {
|
||||||
data: InfiniteData<ConversationListResponse>,
|
conversations: TConversation[];
|
||||||
newConversation: TConversation,
|
nextCursor?: string | null;
|
||||||
): ConversationData => {
|
|
||||||
return addData<ConversationListResponse, TConversation>(
|
|
||||||
data,
|
|
||||||
'conversations',
|
|
||||||
newConversation,
|
|
||||||
(page) =>
|
|
||||||
page.conversations.findIndex((c) => c.conversationId === newConversation.conversationId),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function findPageForConversation(
|
// === InfiniteData helpers for cursor-based convo queries ===
|
||||||
data: ConversationData,
|
|
||||||
conversation: TConversation | { conversationId: string },
|
|
||||||
) {
|
|
||||||
return findPage<ConversationListResponse>(data, (page) =>
|
|
||||||
page.conversations.findIndex((c) => c.conversationId === conversation.conversationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateConversation = (
|
export function findConversationInInfinite(
|
||||||
data: InfiniteData<ConversationListResponse>,
|
data: InfiniteData<ConversationCursorData> | undefined,
|
||||||
newConversation: TConversation,
|
|
||||||
): ConversationData => {
|
|
||||||
return updateData<ConversationListResponse, TConversation>(
|
|
||||||
data,
|
|
||||||
'conversations',
|
|
||||||
newConversation,
|
|
||||||
(page) =>
|
|
||||||
page.conversations.findIndex((c) => c.conversationId === newConversation.conversationId),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateConvoFields = (
|
|
||||||
data: ConversationData,
|
|
||||||
updatedConversation: Partial<TConversation> & Pick<TConversation, 'conversationId'>,
|
|
||||||
keepPosition = false,
|
|
||||||
): ConversationData => {
|
|
||||||
const newData = JSON.parse(JSON.stringify(data));
|
|
||||||
const { pageIndex, index } = findPageForConversation(
|
|
||||||
newData,
|
|
||||||
updatedConversation as { conversationId: string },
|
|
||||||
);
|
|
||||||
if (pageIndex !== -1 && index !== -1) {
|
|
||||||
const oldConversation = newData.pages[pageIndex].conversations[index] as TConversation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do not change the position of the conversation if the tags are updated.
|
|
||||||
*/
|
|
||||||
if (keepPosition) {
|
|
||||||
const updatedConvo = {
|
|
||||||
...oldConversation,
|
|
||||||
...updatedConversation,
|
|
||||||
};
|
|
||||||
newData.pages[pageIndex].conversations[index] = updatedConvo;
|
|
||||||
} else {
|
|
||||||
const updatedConvo = {
|
|
||||||
...oldConversation,
|
|
||||||
...updatedConversation,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
newData.pages[pageIndex].conversations.splice(index, 1);
|
|
||||||
newData.pages[0].conversations.unshift(updatedConvo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteConversation = (
|
|
||||||
data: ConversationData,
|
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): ConversationData => {
|
): TConversation | undefined {
|
||||||
return deleteData<ConversationListResponse, ConversationData>(data, 'conversations', (page) =>
|
if (!data) {
|
||||||
page.conversations.findIndex((c) => c.conversationId === conversationId),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getConversationById = (
|
|
||||||
data: ConversationData | undefined,
|
|
||||||
conversationId: string | null,
|
|
||||||
): TConversation | undefined => {
|
|
||||||
if (!data || !(conversationId ?? '')) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const page of data.pages) {
|
for (const page of data.pages) {
|
||||||
const conversation = page.conversations.find((c) => c.conversationId === conversationId);
|
const found = page.conversations.find((c) => c.conversationId === conversationId);
|
||||||
if (conversation) {
|
if (found) {
|
||||||
return conversation;
|
return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export function updateInfiniteConvoPage(
|
||||||
|
data: InfiniteData<ConversationCursorData> | undefined,
|
||||||
|
conversationId: string,
|
||||||
|
updater: (c: TConversation) => TConversation,
|
||||||
|
): InfiniteData<ConversationCursorData> | undefined {
|
||||||
|
if (!data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: data.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.map((c) =>
|
||||||
|
c.conversationId === conversationId ? updater(c) : c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addConversationToInfinitePages(
|
||||||
|
data: InfiniteData<ConversationCursorData> | undefined,
|
||||||
|
newConversation: TConversation,
|
||||||
|
): InfiniteData<ConversationCursorData> {
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ conversations: [newConversation], nextCursor: null }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: [
|
||||||
|
{ ...data.pages[0], conversations: [newConversation, ...data.pages[0].conversations] },
|
||||||
|
...data.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addConversationToAllConversationsQueries(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
newConversation: TConversation,
|
||||||
|
) {
|
||||||
|
// Find all keys that start with QueryKeys.allConversations
|
||||||
|
const queries = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll([QueryKeys.allConversations], { exact: false });
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (old) => {
|
||||||
|
if (
|
||||||
|
!old ||
|
||||||
|
old.pages[0].conversations.some((c) => c.conversationId === newConversation.conversationId)
|
||||||
|
) {
|
||||||
|
return old;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...old.pages[0],
|
||||||
|
conversations: [newConversation, ...old.pages[0].conversations],
|
||||||
|
},
|
||||||
|
...old.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeConvoFromInfinitePages(
|
||||||
|
data: InfiniteData<ConversationCursorData> | undefined,
|
||||||
|
conversationId: string,
|
||||||
|
): InfiniteData<ConversationCursorData> | undefined {
|
||||||
|
if (!data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: data.pages
|
||||||
|
.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
||||||
|
}))
|
||||||
|
.filter((page) => page.conversations.length > 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for partial update (e.g., title, etc.), updating AND possibly bumping to front of visible convos
|
||||||
|
export function updateConvoFieldsInfinite(
|
||||||
|
data: InfiniteData<ConversationCursorData> | undefined,
|
||||||
|
updatedConversation: Partial<TConversation> & { conversationId: string },
|
||||||
|
keepPosition = false,
|
||||||
|
): InfiniteData<ConversationCursorData> | undefined {
|
||||||
|
if (!data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
let found: TConversation | undefined;
|
||||||
|
let pageIdx = -1,
|
||||||
|
convoIdx = -1;
|
||||||
|
for (let i = 0; i < data.pages.length; ++i) {
|
||||||
|
const idx = data.pages[i].conversations.findIndex(
|
||||||
|
(c) => c.conversationId === updatedConversation.conversationId,
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
pageIdx = i;
|
||||||
|
convoIdx = idx;
|
||||||
|
found = data.pages[i].conversations[idx];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepPosition) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: data.pages.map((page, pi) =>
|
||||||
|
pi === pageIdx
|
||||||
|
? {
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.map((c, ci) =>
|
||||||
|
ci === convoIdx ? { ...c, ...updatedConversation } : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: page,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const patched = { ...found, ...updatedConversation, updatedAt: new Date().toISOString() };
|
||||||
|
const pages = data.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter((c) => c.conversationId !== patched.conversationId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
pages[0].conversations = [patched, ...pages[0].conversations];
|
||||||
|
|
||||||
|
const finalPages = pages.filter((page) => page.conversations.length > 0);
|
||||||
|
return { ...data, pages: finalPages };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function storeEndpointSettings(conversation: TConversation | null) {
|
export function storeEndpointSettings(conversation: TConversation | null) {
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { endpoint, model, agentOptions } = conversation;
|
const { endpoint, model, agentOptions } = conversation;
|
||||||
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastModel = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '{}');
|
const lastModel = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '{}');
|
||||||
lastModel[endpoint] = model;
|
lastModel[endpoint] = model;
|
||||||
|
|
||||||
if (endpoint === EModelEndpoint.gptPlugins) {
|
if (endpoint === EModelEndpoint.gptPlugins) {
|
||||||
lastModel.secondaryModel = agentOptions?.model ?? model ?? '';
|
lastModel.secondaryModel = agentOptions?.model ?? model ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(lastModel));
|
localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(lastModel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add
|
||||||
|
export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConversation) {
|
||||||
|
const queries = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll([QueryKeys.allConversations], { exact: false });
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
oldData.pages.some((p) =>
|
||||||
|
p.conversations.some((c) => c.conversationId === newConvo.conversationId),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...oldData.pages[0],
|
||||||
|
conversations: [newConvo, ...oldData.pages[0].conversations],
|
||||||
|
},
|
||||||
|
...oldData.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
export function updateConvoInAllQueries(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
conversationId: string,
|
||||||
|
updater: (c: TConversation) => TConversation,
|
||||||
|
) {
|
||||||
|
const queries = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll([QueryKeys.allConversations], { exact: false });
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pages: oldData.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.map((c) =>
|
||||||
|
c.conversationId === conversationId ? updater(c) : c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
export function removeConvoFromAllQueries(queryClient: QueryClient, conversationId: string) {
|
||||||
|
const queries = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll([QueryKeys.allConversations], { exact: false });
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pages: oldData.pages
|
||||||
|
.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
||||||
|
}))
|
||||||
|
.filter((page) => page.conversations.length > 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
660
package-lock.json
generated
660
package-lock.json
generated
|
|
@ -55,7 +55,7 @@
|
||||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||||
"@azure/identity": "^4.7.0",
|
"@azure/identity": "^4.7.0",
|
||||||
"@azure/search-documents": "^12.0.0",
|
"@azure/search-documents": "^12.0.0",
|
||||||
"@azure/storage-blob": "^12.26.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@google/generative-ai": "^0.23.0",
|
"@google/generative-ai": "^0.23.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
"@langchain/google-genai": "^0.2.2",
|
"@langchain/google-genai": "^0.2.2",
|
||||||
"@langchain/google-vertexai": "^0.2.3",
|
"@langchain/google-vertexai": "^0.2.3",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.4.17",
|
"@librechat/agents": "^2.4.20",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
|
|
@ -711,31 +711,6 @@
|
||||||
"@langchain/core": ">=0.3.39 <0.4.0"
|
"@langchain/core": ">=0.3.39 <0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api/node_modules/@librechat/agents": {
|
|
||||||
"version": "2.4.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.17.tgz",
|
|
||||||
"integrity": "sha512-lGgOqovIqzaFtO3wUe8LShckJYmFGxa/RAn1edUxmQgK76F4QK53POFivzQhYUxso9z4SNvu1b8q/+vq7lWYaw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@langchain/anthropic": "^0.3.16",
|
|
||||||
"@langchain/aws": "^0.1.7",
|
|
||||||
"@langchain/community": "^0.3.39",
|
|
||||||
"@langchain/core": "^0.3.43",
|
|
||||||
"@langchain/deepseek": "^0.0.1",
|
|
||||||
"@langchain/google-genai": "^0.2.2",
|
|
||||||
"@langchain/google-vertexai": "^0.2.3",
|
|
||||||
"@langchain/langgraph": "^0.2.62",
|
|
||||||
"@langchain/mistralai": "^0.2.0",
|
|
||||||
"@langchain/ollama": "^0.2.0",
|
|
||||||
"@langchain/openai": "^0.5.4",
|
|
||||||
"@langchain/xai": "^0.0.2",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"nanoid": "^3.3.7"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api/node_modules/@types/node": {
|
"api/node_modules/@types/node": {
|
||||||
"version": "18.19.14",
|
"version": "18.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz",
|
||||||
|
|
@ -1279,6 +1254,7 @@
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/node": "^20.3.0",
|
"@types/node": "^20.3.0",
|
||||||
"@types/react": "^18.2.11",
|
"@types/react": "^18.2.11",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
|
|
@ -17628,11 +17604,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@langchain/langgraph": {
|
"node_modules/@langchain/langgraph": {
|
||||||
"version": "0.2.63",
|
"version": "0.2.64",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.63.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.64.tgz",
|
||||||
"integrity": "sha512-gmivnxybyBQkR7lpoBz8emrWRt9jFp0csrlDZTI/SlMJ5F+3DvxbsRyH2edI0RPzL7XpiW9QnFGCLMp6wfGblw==",
|
"integrity": "sha512-M6lh8ekDoZVCLdA10jeqIsU58LODDzXpP38aeXil5A5pg31IJp5L8O4yBfbp8mRobVX+Bbga5R5ZRyQBQl6NTg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/langgraph-checkpoint": "~0.0.16",
|
"@langchain/langgraph-checkpoint": "~0.0.17",
|
||||||
"@langchain/langgraph-sdk": "~0.0.32",
|
"@langchain/langgraph-sdk": "~0.0.32",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|
@ -17651,9 +17628,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@langchain/langgraph-checkpoint": {
|
"node_modules/@langchain/langgraph-checkpoint": {
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.17.tgz",
|
||||||
"integrity": "sha512-B50l7w9o9353drHsdsD01vhQrCJw0eqvYeXid7oKeoj1Yye+qY90r97xuhiflaYCZHM5VEo2oaizs8oknerZsQ==",
|
"integrity": "sha512-6b3CuVVYx+7x0uWLG+7YXz9j2iBa+tn2AXvkLxzEvaAsLE6Sij++8PPbS2BZzC+S/FPJdWsz6I5bsrqL0BYrCA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -17672,6 +17650,7 @@
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
|
|
@ -17680,6 +17659,7 @@
|
||||||
"version": "0.0.66",
|
"version": "0.0.66",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.66.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.66.tgz",
|
||||||
"integrity": "sha512-l0V4yfKXhHaTRK/1bKMfZ14k3wWZu27DWTlCUnbYJvdo7os5srhONgPCOqQgpazhi5EhXbW2EVgeu/wLW2zH6Q==",
|
"integrity": "sha512-l0V4yfKXhHaTRK/1bKMfZ14k3wWZu27DWTlCUnbYJvdo7os5srhONgPCOqQgpazhi5EhXbW2EVgeu/wLW2zH6Q==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"p-queue": "^6.6.2",
|
"p-queue": "^6.6.2",
|
||||||
|
|
@ -17707,6 +17687,7 @@
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
|
|
@ -17866,6 +17847,610 @@
|
||||||
"@lezer/common": "^1.0.0"
|
"@lezer/common": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@librechat/agents": {
|
||||||
|
"version": "2.4.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.20.tgz",
|
||||||
|
"integrity": "sha512-Wnrx123ZSrGkYE9P/pdXpWmPp+XPsAWrTwk3H3l1nN3UXLqb2E75V3i8UEoFvTMkya006p76+Rt/fHNT9y9E5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@langchain/anthropic": "^0.3.16",
|
||||||
|
"@langchain/aws": "^0.1.7",
|
||||||
|
"@langchain/community": "^0.3.39",
|
||||||
|
"@langchain/core": "^0.3.43",
|
||||||
|
"@langchain/deepseek": "^0.0.1",
|
||||||
|
"@langchain/google-genai": "^0.2.2",
|
||||||
|
"@langchain/google-vertexai": "^0.2.3",
|
||||||
|
"@langchain/langgraph": "^0.2.62",
|
||||||
|
"@langchain/mistralai": "^0.2.0",
|
||||||
|
"@langchain/ollama": "^0.2.0",
|
||||||
|
"@langchain/openai": "^0.5.4",
|
||||||
|
"@langchain/xai": "^0.0.2",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"nanoid": "^3.3.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@librechat/agents/node_modules/@langchain/community": {
|
||||||
|
"version": "0.3.40",
|
||||||
|
"resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.40.tgz",
|
||||||
|
"integrity": "sha512-UvpEebdFKJsjFBKeUOvvYHOEFsUcjZnyU1qNirtDajwjzTJlszXtv+Mq8F6w5mJsznpI9x7ZMNzAqydVxMG5hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@langchain/openai": ">=0.2.0 <0.6.0",
|
||||||
|
"binary-extensions": "^2.2.0",
|
||||||
|
"expr-eval": "^2.0.2",
|
||||||
|
"flat": "^5.0.2",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"langchain": ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0",
|
||||||
|
"langsmith": ">=0.2.8 <0.4.0",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"zod": "^3.22.3",
|
||||||
|
"zod-to-json-schema": "^3.22.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@arcjet/redact": "^v1.0.0-alpha.23",
|
||||||
|
"@aws-crypto/sha256-js": "^5.0.0",
|
||||||
|
"@aws-sdk/client-bedrock-agent-runtime": "^3.749.0",
|
||||||
|
"@aws-sdk/client-bedrock-runtime": "^3.749.0",
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.749.0",
|
||||||
|
"@aws-sdk/client-kendra": "^3.749.0",
|
||||||
|
"@aws-sdk/client-lambda": "^3.749.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.749.0",
|
||||||
|
"@aws-sdk/client-sagemaker-runtime": "^3.749.0",
|
||||||
|
"@aws-sdk/client-sfn": "^3.749.0",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.388.0",
|
||||||
|
"@azure/search-documents": "^12.0.0",
|
||||||
|
"@azure/storage-blob": "^12.15.0",
|
||||||
|
"@browserbasehq/sdk": "*",
|
||||||
|
"@browserbasehq/stagehand": "^1.0.0",
|
||||||
|
"@clickhouse/client": "^0.2.5",
|
||||||
|
"@cloudflare/ai": "*",
|
||||||
|
"@datastax/astra-db-ts": "^1.0.0",
|
||||||
|
"@elastic/elasticsearch": "^8.4.0",
|
||||||
|
"@getmetal/metal-sdk": "*",
|
||||||
|
"@getzep/zep-cloud": "^1.0.6",
|
||||||
|
"@getzep/zep-js": "^0.9.0",
|
||||||
|
"@gomomento/sdk": "^1.51.1",
|
||||||
|
"@gomomento/sdk-core": "^1.51.1",
|
||||||
|
"@google-ai/generativelanguage": "*",
|
||||||
|
"@google-cloud/storage": "^6.10.1 || ^7.7.0",
|
||||||
|
"@gradientai/nodejs-sdk": "^1.2.0",
|
||||||
|
"@huggingface/inference": "^2.6.4",
|
||||||
|
"@huggingface/transformers": "^3.2.3",
|
||||||
|
"@ibm-cloud/watsonx-ai": "*",
|
||||||
|
"@lancedb/lancedb": "^0.12.0",
|
||||||
|
"@langchain/core": ">=0.2.21 <0.4.0",
|
||||||
|
"@layerup/layerup-security": "^1.5.12",
|
||||||
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@mendable/firecrawl-js": "^1.4.3",
|
||||||
|
"@mlc-ai/web-llm": "*",
|
||||||
|
"@mozilla/readability": "*",
|
||||||
|
"@neondatabase/serverless": "*",
|
||||||
|
"@notionhq/client": "^2.2.10",
|
||||||
|
"@opensearch-project/opensearch": "*",
|
||||||
|
"@pinecone-database/pinecone": "*",
|
||||||
|
"@planetscale/database": "^1.8.0",
|
||||||
|
"@premai/prem-sdk": "^0.3.25",
|
||||||
|
"@qdrant/js-client-rest": "^1.8.2",
|
||||||
|
"@raycast/api": "^1.55.2",
|
||||||
|
"@rockset/client": "^0.9.1",
|
||||||
|
"@smithy/eventstream-codec": "^2.0.5",
|
||||||
|
"@smithy/protocol-http": "^3.0.6",
|
||||||
|
"@smithy/signature-v4": "^2.0.10",
|
||||||
|
"@smithy/util-utf8": "^2.0.0",
|
||||||
|
"@spider-cloud/spider-client": "^0.0.21",
|
||||||
|
"@supabase/supabase-js": "^2.45.0",
|
||||||
|
"@tensorflow-models/universal-sentence-encoder": "*",
|
||||||
|
"@tensorflow/tfjs-converter": "*",
|
||||||
|
"@tensorflow/tfjs-core": "*",
|
||||||
|
"@upstash/ratelimit": "^1.1.3 || ^2.0.3",
|
||||||
|
"@upstash/redis": "^1.20.6",
|
||||||
|
"@upstash/vector": "^1.1.1",
|
||||||
|
"@vercel/kv": "*",
|
||||||
|
"@vercel/postgres": "*",
|
||||||
|
"@writerai/writer-sdk": "^0.40.2",
|
||||||
|
"@xata.io/client": "^0.28.0",
|
||||||
|
"@zilliz/milvus2-sdk-node": ">=2.3.5",
|
||||||
|
"apify-client": "^2.7.1",
|
||||||
|
"assemblyai": "^4.6.0",
|
||||||
|
"azion": "^1.11.1",
|
||||||
|
"better-sqlite3": ">=9.4.0 <12.0.0",
|
||||||
|
"cassandra-driver": "^4.7.2",
|
||||||
|
"cborg": "^4.1.1",
|
||||||
|
"cheerio": "^1.0.0-rc.12",
|
||||||
|
"chromadb": "*",
|
||||||
|
"closevector-common": "0.1.3",
|
||||||
|
"closevector-node": "0.1.6",
|
||||||
|
"closevector-web": "0.1.6",
|
||||||
|
"cohere-ai": "*",
|
||||||
|
"convex": "^1.3.1",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"d3-dsv": "^2.0.0",
|
||||||
|
"discord.js": "^14.14.1",
|
||||||
|
"dria": "^0.0.3",
|
||||||
|
"duck-duck-scrape": "^2.2.5",
|
||||||
|
"epub2": "^3.0.1",
|
||||||
|
"fast-xml-parser": "*",
|
||||||
|
"firebase-admin": "^11.9.0 || ^12.0.0",
|
||||||
|
"google-auth-library": "*",
|
||||||
|
"googleapis": "*",
|
||||||
|
"hnswlib-node": "^3.0.0",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
|
"ibm-cloud-sdk-core": "*",
|
||||||
|
"ignore": "^5.2.0",
|
||||||
|
"interface-datastore": "^8.2.11",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"it-all": "^3.0.4",
|
||||||
|
"jsdom": "*",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"llmonitor": "^0.5.9",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lunary": "^0.7.10",
|
||||||
|
"mammoth": "^1.6.0",
|
||||||
|
"mariadb": "^3.4.0",
|
||||||
|
"mem0ai": "^2.1.8",
|
||||||
|
"mongodb": ">=5.2.0",
|
||||||
|
"mysql2": "^3.9.8",
|
||||||
|
"neo4j-driver": "*",
|
||||||
|
"notion-to-md": "^3.1.0",
|
||||||
|
"officeparser": "^4.0.4",
|
||||||
|
"openai": "*",
|
||||||
|
"pdf-parse": "1.1.1",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"pg-copy-streams": "^6.0.5",
|
||||||
|
"pickleparser": "^0.2.1",
|
||||||
|
"playwright": "^1.32.1",
|
||||||
|
"portkey-ai": "^0.1.11",
|
||||||
|
"puppeteer": "*",
|
||||||
|
"pyodide": ">=0.24.1 <0.27.0",
|
||||||
|
"redis": "*",
|
||||||
|
"replicate": "*",
|
||||||
|
"sonix-speech-recognition": "^2.1.1",
|
||||||
|
"srt-parser-2": "^1.2.3",
|
||||||
|
"typeorm": "^0.3.20",
|
||||||
|
"typesense": "^1.5.3",
|
||||||
|
"usearch": "^1.1.1",
|
||||||
|
"voy-search": "0.6.2",
|
||||||
|
"weaviate-ts-client": "*",
|
||||||
|
"web-auth-library": "^1.0.3",
|
||||||
|
"word-extractor": "*",
|
||||||
|
"ws": "^8.14.2",
|
||||||
|
"youtubei.js": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@arcjet/redact": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-crypto/sha256-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-bedrock-agent-runtime": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-bedrock-runtime": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-dynamodb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-kendra": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-lambda": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-s3": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-sagemaker-runtime": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/client-sfn": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/credential-provider-node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@aws-sdk/dsql-signer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@azure/search-documents": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@azure/storage-blob": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@browserbasehq/sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@clickhouse/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@cloudflare/ai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@datastax/astra-db-ts": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@elastic/elasticsearch": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@getmetal/metal-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@getzep/zep-cloud": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@getzep/zep-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@gomomento/sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@gomomento/sdk-core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@google-ai/generativelanguage": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@google-cloud/storage": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@gradientai/nodejs-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@huggingface/inference": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@huggingface/transformers": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@lancedb/lancedb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@layerup/layerup-security": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@libsql/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mendable/firecrawl-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mlc-ai/web-llm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mozilla/readability": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@neondatabase/serverless": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@notionhq/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opensearch-project/opensearch": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@pinecone-database/pinecone": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@planetscale/database": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@premai/prem-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@qdrant/js-client-rest": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@raycast/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@rockset/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@smithy/eventstream-codec": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@smithy/protocol-http": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@smithy/signature-v4": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@smithy/util-utf8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@spider-cloud/spider-client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@supabase/supabase-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@tensorflow-models/universal-sentence-encoder": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@tensorflow/tfjs-converter": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@tensorflow/tfjs-core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@upstash/ratelimit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@upstash/redis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@upstash/vector": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vercel/kv": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vercel/postgres": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@writerai/writer-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@xata.io/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@zilliz/milvus2-sdk-node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"apify-client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"assemblyai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"azion": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"better-sqlite3": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cassandra-driver": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cborg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cheerio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"chromadb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"closevector-common": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"closevector-node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"closevector-web": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cohere-ai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"convex": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"crypto-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"d3-dsv": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"discord.js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"dria": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"duck-duck-scrape": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"epub2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"fast-xml-parser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"firebase-admin": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"google-auth-library": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"googleapis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"hnswlib-node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"html-to-text": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ignore": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"interface-datastore": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"it-all": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsonwebtoken": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"llmonitor": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"lodash": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"lunary": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mammoth": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mariadb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mem0ai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mysql2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"neo4j-driver": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"notion-to-md": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"officeparser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pdf-parse": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg-copy-streams": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pickleparser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"portkey-ai": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"puppeteer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pyodide": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"replicate": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sonix-speech-recognition": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"srt-parser-2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typeorm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typesense": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"usearch": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"voy-search": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"weaviate-ts-client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"web-auth-library": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"word-extractor": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"youtubei.js": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@librechat/agents/node_modules/@langchain/openai": {
|
||||||
|
"version": "0.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.5.tgz",
|
||||||
|
"integrity": "sha512-QwdZrWcx6FB+UMKQ6+a0M9ZXzeUnZCwXP7ltqCCycPzdfiwxg3TQ6WkSefdEyiPpJcVVq/9HZSxrzGmf18QGyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tiktoken": "^1.0.12",
|
||||||
|
"openai": "^4.87.3",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"zod-to-json-schema": "^3.22.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@langchain/core": ">=0.3.39 <0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@librechat/agents/node_modules/agent-base": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@librechat/agents/node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@librechat/agents/node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@librechat/backend": {
|
"node_modules/@librechat/backend": {
|
||||||
"resolved": "api",
|
"resolved": "api",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -22981,6 +23566,13 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||||
|
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
import type { AssistantsEndpoint } from './schemas';
|
import type { AssistantsEndpoint } from './schemas';
|
||||||
|
|
||||||
|
// Testing this buildQuery function
|
||||||
|
const buildQuery = (params: Record<string, unknown>): string => {
|
||||||
|
const query = Object.entries(params)
|
||||||
|
.filter(([, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return value !== undefined && value !== null && value !== '';
|
||||||
|
})
|
||||||
|
.map(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => `${key}=${encodeURIComponent(v)}`).join('&');
|
||||||
|
}
|
||||||
|
return `${key}=${encodeURIComponent(String(value))}`;
|
||||||
|
})
|
||||||
|
.join('&');
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
export const health = () => '/health';
|
export const health = () => '/health';
|
||||||
export const user = () => '/api/user';
|
export const user = () => '/api/user';
|
||||||
|
|
||||||
|
|
@ -43,10 +62,24 @@ export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`;
|
||||||
|
|
||||||
export const conversationsRoot = '/api/convos';
|
export const conversationsRoot = '/api/convos';
|
||||||
|
|
||||||
export const conversations = (pageNumber: string, isArchived?: boolean, tags?: string[]) =>
|
export const conversations = (
|
||||||
`${conversationsRoot}?pageNumber=${pageNumber}${
|
isArchived?: boolean,
|
||||||
isArchived === true ? '&isArchived=true' : ''
|
sortBy?: 'title' | 'createdAt' | 'updatedAt',
|
||||||
}${tags?.map((tag) => `&tags=${tag}`).join('')}`;
|
sortDirection?: 'asc' | 'desc',
|
||||||
|
tags?: string[],
|
||||||
|
search?: string,
|
||||||
|
cursor?: string,
|
||||||
|
) => {
|
||||||
|
const params = {
|
||||||
|
isArchived,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
tags,
|
||||||
|
search,
|
||||||
|
cursor,
|
||||||
|
};
|
||||||
|
return `${conversationsRoot}${buildQuery(params)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
|
export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
|
||||||
|
|
||||||
|
|
@ -54,7 +87,9 @@ export const genTitle = () => `${conversationsRoot}/gen_title`;
|
||||||
|
|
||||||
export const updateConversation = () => `${conversationsRoot}/update`;
|
export const updateConversation = () => `${conversationsRoot}/update`;
|
||||||
|
|
||||||
export const deleteConversation = () => `${conversationsRoot}/clear`;
|
export const deleteConversation = () => `${conversationsRoot}`;
|
||||||
|
|
||||||
|
export const deleteAllConversation = () => `${conversationsRoot}/all`;
|
||||||
|
|
||||||
export const importConversation = () => `${conversationsRoot}/import`;
|
export const importConversation = () => `${conversationsRoot}/import`;
|
||||||
|
|
||||||
|
|
@ -62,8 +97,8 @@ export const forkConversation = () => `${conversationsRoot}/fork`;
|
||||||
|
|
||||||
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
|
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
|
||||||
|
|
||||||
export const search = (q: string, pageNumber: string) =>
|
export const search = (q: string, cursor?: string | null) =>
|
||||||
`/api/search?q=${q}&pageNumber=${pageNumber}`;
|
`/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`;
|
||||||
|
|
||||||
export const searchEnabled = () => '/api/search/enable';
|
export const searchEnabled = () => '/api/search/enable';
|
||||||
|
|
||||||
|
|
@ -244,4 +279,4 @@ export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
||||||
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
||||||
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
||||||
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
||||||
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
||||||
|
|
|
||||||
|
|
@ -1228,6 +1228,8 @@ export enum Constants {
|
||||||
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
||||||
/** Standard value for the initial conversationId before a request is sent */
|
/** Standard value for the initial conversationId before a request is sent */
|
||||||
NEW_CONVO = 'new',
|
NEW_CONVO = 'new',
|
||||||
|
/** Standard value for the temporary conversationId after a request is sent and before the server responds */
|
||||||
|
PENDING_CONVO = 'PENDING',
|
||||||
/** Standard value for the conversationId used for search queries */
|
/** Standard value for the conversationId used for search queries */
|
||||||
SEARCH = 'search',
|
SEARCH = 'search',
|
||||||
/** Fixed, encoded domain length for Azure OpenAI Assistants Function name parsing. */
|
/** Fixed, encoded domain length for Azure OpenAI Assistants Function name parsing. */
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ export function deleteUser(): Promise<s.TPreset> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
|
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
|
||||||
if (conversationId === 'new') {
|
if (
|
||||||
|
conversationId === config.Constants.NEW_CONVO ||
|
||||||
|
conversationId === config.Constants.PENDING_CONVO
|
||||||
|
) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
return request.get(endpoints.messages(conversationId));
|
return request.get(endpoints.messages(conversationId));
|
||||||
|
|
@ -589,46 +592,34 @@ export function forkConversation(payload: t.TForkConvoRequest): Promise<t.TForkC
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteConversation(payload: t.TDeleteConversationRequest) {
|
export function deleteConversation(payload: t.TDeleteConversationRequest) {
|
||||||
//todo: this should be a DELETE request
|
return request.deleteWithOptions(endpoints.deleteConversation(), { data: { arg: payload } });
|
||||||
return request.post(endpoints.deleteConversation(), { arg: payload });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAllConversations(): Promise<unknown> {
|
export function clearAllConversations(): Promise<unknown> {
|
||||||
return request.post(endpoints.deleteConversation(), { arg: {} });
|
return request.delete(endpoints.deleteAllConversation());
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listConversations = (
|
export const listConversations = (
|
||||||
params?: q.ConversationListParams,
|
params?: q.ConversationListParams,
|
||||||
): Promise<q.ConversationListResponse> => {
|
): Promise<q.ConversationListResponse> => {
|
||||||
// Assuming params has a pageNumber property
|
const isArchived = params?.isArchived ?? false;
|
||||||
const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
|
const sortBy = params?.sortBy;
|
||||||
const isArchived = params?.isArchived ?? false; // Default to false if not provided
|
const sortDirection = params?.sortDirection;
|
||||||
const tags = params?.tags || []; // Default to an empty array if not provided
|
const tags = params?.tags || [];
|
||||||
return request.get(endpoints.conversations(pageNumber, isArchived, tags));
|
const search = params?.search || '';
|
||||||
};
|
const cursor = params?.cursor;
|
||||||
|
|
||||||
export const listConversationsByQuery = (
|
if (search !== '' && isArchived === false) {
|
||||||
params?: q.ConversationListParams & { searchQuery?: string },
|
return request.get(endpoints.search(search, cursor));
|
||||||
): Promise<q.ConversationListResponse> => {
|
|
||||||
const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
|
|
||||||
const searchQuery = params?.searchQuery ?? ''; // If no search query is provided, default to an empty string
|
|
||||||
// Update the endpoint to handle a search query
|
|
||||||
if (searchQuery !== '') {
|
|
||||||
return request.get(endpoints.search(searchQuery, pageNumber));
|
|
||||||
} else {
|
} else {
|
||||||
return request.get(endpoints.conversations(pageNumber));
|
return request.get(
|
||||||
|
endpoints.conversations(isArchived, sortBy, sortDirection, tags, search, cursor),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchConversations = async (
|
export function getConversations(cursor: string): Promise<t.TGetConversationsResponse> {
|
||||||
q: string,
|
return request.get(endpoints.conversations(undefined, undefined, undefined, [], '', cursor));
|
||||||
pageNumber: string,
|
|
||||||
): Promise<t.TSearchResults> => {
|
|
||||||
return request.get(endpoints.search(q, pageNumber));
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getConversations(pageNumber: string): Promise<t.TGetConversationsResponse> {
|
|
||||||
return request.get(endpoints.conversations(pageNumber));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversationById(id: string): Promise<s.TConversation> {
|
export function getConversationById(id: string): Promise<s.TConversation> {
|
||||||
|
|
@ -779,15 +770,11 @@ export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||||
return request.get(endpoints.enableTwoFactor());
|
return request.get(endpoints.enableTwoFactor());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyTwoFactor(
|
export function verifyTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerify2FAResponse> {
|
||||||
payload: t.TVerify2FARequest,
|
|
||||||
): Promise<t.TVerify2FAResponse> {
|
|
||||||
return request.post(endpoints.verifyTwoFactor(), payload);
|
return request.post(endpoints.verifyTwoFactor(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmTwoFactor(
|
export function confirmTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerify2FAResponse> {
|
||||||
payload: t.TVerify2FARequest,
|
|
||||||
): Promise<t.TVerify2FAResponse> {
|
|
||||||
return request.post(endpoints.confirmTwoFactor(), payload);
|
return request.post(endpoints.confirmTwoFactor(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -803,4 +790,4 @@ export function verifyTwoFactorTemp(
|
||||||
payload: t.TVerify2FATempRequest,
|
payload: t.TVerify2FATempRequest,
|
||||||
): Promise<t.TVerify2FATempResponse> {
|
): Promise<t.TVerify2FATempResponse> {
|
||||||
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ type EndpointSchema =
|
||||||
| typeof compactAgentsSchema
|
| typeof compactAgentsSchema
|
||||||
| typeof bedrockInputSchema;
|
| typeof bedrockInputSchema;
|
||||||
|
|
||||||
type EndpointSchemaKey = Exclude<EModelEndpoint, EModelEndpoint.chatGPTBrowser>;
|
export type EndpointSchemaKey = Exclude<EModelEndpoint, EModelEndpoint.chatGPTBrowser>;
|
||||||
|
|
||||||
const endpointSchemas: Record<EndpointSchemaKey, EndpointSchema> = {
|
const endpointSchemas: Record<EndpointSchemaKey, EndpointSchema> = {
|
||||||
[EModelEndpoint.openAI]: openAISchema,
|
[EModelEndpoint.openAI]: openAISchema,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
UseMutationResult,
|
UseMutationResult,
|
||||||
QueryObserverResult,
|
QueryObserverResult,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import { initialModelsConfig } from '../config';
|
import { Constants, initialModelsConfig } from '../config';
|
||||||
import { defaultOrderQuery } from '../types/assistants';
|
import { defaultOrderQuery } from '../types/assistants';
|
||||||
import * as dataService from '../data-service';
|
import * as dataService from '../data-service';
|
||||||
import * as m from '../types/mutations';
|
import * as m from '../types/mutations';
|
||||||
|
|
@ -70,6 +70,10 @@ export const useGetSharedLinkQuery = (
|
||||||
[QueryKeys.sharedLinks, conversationId],
|
[QueryKeys.sharedLinks, conversationId],
|
||||||
() => dataService.getSharedLink(conversationId),
|
() => dataService.getSharedLink(conversationId),
|
||||||
{
|
{
|
||||||
|
enabled:
|
||||||
|
!!conversationId &&
|
||||||
|
conversationId !== Constants.NEW_CONVO &&
|
||||||
|
conversationId !== Constants.PENDING_CONVO,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|
@ -242,23 +246,6 @@ export const useDeletePresetMutation = (): UseMutationResult<
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSearchQuery = (
|
|
||||||
searchQuery: string,
|
|
||||||
pageNumber: string,
|
|
||||||
config?: UseQueryOptions<t.TSearchResults>,
|
|
||||||
): QueryObserverResult<t.TSearchResults> => {
|
|
||||||
return useQuery<t.TSearchResults>(
|
|
||||||
[QueryKeys.searchResults, pageNumber, searchQuery],
|
|
||||||
() => dataService.searchConversations(searchQuery, pageNumber),
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
...config,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateTokenCountMutation = (): UseMutationResult<
|
export const useUpdateTokenCountMutation = (): UseMutationResult<
|
||||||
t.TUpdateTokenCountResponse,
|
t.TUpdateTokenCountResponse,
|
||||||
unknown,
|
unknown,
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,8 @@ export const tPresetSchema = tConversationSchema
|
||||||
export const tConvoUpdateSchema = tConversationSchema.merge(
|
export const tConvoUpdateSchema = tConversationSchema.merge(
|
||||||
z.object({
|
z.object({
|
||||||
endpoint: extendedModelEndpointSchema.nullable(),
|
endpoint: extendedModelEndpointSchema.nullable(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@ export type DeleteConversationOptions = MutationOptions<
|
||||||
types.TDeleteConversationRequest
|
types.TDeleteConversationRequest
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ArchiveConversationOptions = MutationOptions<
|
||||||
|
types.TArchiveConversationResponse,
|
||||||
|
types.TArchiveConversationRequest
|
||||||
|
>;
|
||||||
|
|
||||||
export type DuplicateConvoOptions = MutationOptions<
|
export type DuplicateConvoOptions = MutationOptions<
|
||||||
types.TDuplicateConvoResponse,
|
types.TDuplicateConvoResponse,
|
||||||
types.TDuplicateConvoRequest
|
types.TDuplicateConvoRequest
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,38 @@ export type Conversation = {
|
||||||
conversations: s.TConversation[];
|
conversations: s.TConversation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parameters for listing conversations (e.g., for pagination)
|
|
||||||
export type ConversationListParams = {
|
export type ConversationListParams = {
|
||||||
limit?: number;
|
cursor?: string;
|
||||||
before?: string | null;
|
|
||||||
after?: string | null;
|
|
||||||
order?: 'asc' | 'desc';
|
|
||||||
pageNumber: string;
|
|
||||||
conversationId?: string;
|
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
|
sortBy?: 'title' | 'createdAt' | 'updatedAt';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Type for the response from the conversation list API
|
export type MinimalConversation = Pick<
|
||||||
|
s.TConversation,
|
||||||
|
'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user'
|
||||||
|
>;
|
||||||
|
|
||||||
export type ConversationListResponse = {
|
export type ConversationListResponse = {
|
||||||
conversations: s.TConversation[];
|
conversations: MinimalConversation[];
|
||||||
pageNumber: string;
|
mwssages?: s.TMessage[];
|
||||||
pageSize: string | number;
|
nextCursor: string | null;
|
||||||
pages: string | number;
|
};
|
||||||
|
|
||||||
|
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[];
|
messages: s.TMessage[];
|
||||||
|
nextCursor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationData = InfiniteData<ConversationListResponse>;
|
export type ConversationData = InfiniteData<ConversationListResponse>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue