🔗 feat: Enhance Share Functionality, Optimize DataTable & Fix Critical Bugs (#5220)

* 🔄 refactor: frontend and backend share link logic; feat: qrcode for share link; feat: refresh link

* 🐛 fix: Conditionally render shared link and refactor share link creation logic

* 🐛 fix: Correct conditional check for shareId in ShareButton component

* 🔄 refactor: Update shared links API and data handling; improve query parameters and response structure

* 🔄 refactor: Update shared links pagination and response structure; replace pageNumber with cursor for improved data fetching

* 🔄 refactor: DataTable performance optimization

* fix: delete shared link cache update

* 🔄 refactor: Enhance shared links functionality; add conversationId to shared link model and update related components

* 🔄 refactor: Add delete functionality to SharedLinkButton; integrate delete mutation and confirmation dialog

* 🔄 feat: Add AnimatedSearchInput component with gradient animations and search functionality; update search handling in API and localization

* 🔄 refactor: Improve SharedLinks component; enhance delete functionality and loading states, optimize AnimatedSearchInput, and refine DataTable scrolling behavior

* fix: mutation type issues with deleted shared link mutation

* fix: MutationOptions types

* fix: Ensure only public shared links are retrieved in getSharedLink function

* fix: `qrcode.react` install location

* fix: ensure non-public shared links are not fetched when checking for existing shared links, and remove deprecated .exec() method for queries

* fix: types and import order

* refactor: cleanup share button UI logic, make more intuitive

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-01-21 15:31:05 +01:00 committed by GitHub
parent 460cde0c0b
commit fa9e778399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1779 additions and 1975 deletions

View file

@ -1,82 +1,71 @@
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { Conversation } = require('~/models/Conversation');
const SharedLink = require('./schema/shareSchema'); const SharedLink = require('./schema/shareSchema');
const { getMessages } = require('./Message'); const { getMessages } = require('./Message');
const logger = require('~/config/winston'); const logger = require('~/config/winston');
/** class ShareServiceError extends Error {
* Anonymizes a conversation ID constructor(message, code) {
* @returns {string} The anonymized conversation ID super(message);
*/ this.name = 'ShareServiceError';
function anonymizeConvoId() { this.code = code;
return `convo_${nanoid()}`; }
} }
/** const memoizedAnonymizeId = (prefix) => {
* Anonymizes an assistant ID const memo = new Map();
* @returns {string} The anonymized assistant ID return (id) => {
*/ if (!memo.has(id)) {
function anonymizeAssistantId() { memo.set(id, `${prefix}_${nanoid()}`);
return `a_${nanoid()}`; }
} return memo.get(id);
};
};
/** const anonymizeConvoId = memoizedAnonymizeId('convo');
* Anonymizes a message ID const anonymizeAssistantId = memoizedAnonymizeId('a');
* @param {string} id - The original message ID const anonymizeMessageId = (id) =>
* @returns {string} The anonymized message ID id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
*/
function anonymizeMessageId(id) {
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
}
/**
* Anonymizes a conversation object
* @param {object} conversation - The conversation object
* @returns {object} The anonymized conversation object
*/
function anonymizeConvo(conversation) { function anonymizeConvo(conversation) {
if (!conversation) {
return null;
}
const newConvo = { ...conversation }; const newConvo = { ...conversation };
if (newConvo.assistant_id) { if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId(); newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
} }
return newConvo; return newConvo;
} }
/**
* Anonymizes messages in a conversation
* @param {TMessage[]} messages - The original messages
* @param {string} newConvoId - The new conversation ID
* @returns {TMessage[]} The anonymized messages
*/
function anonymizeMessages(messages, newConvoId) { function anonymizeMessages(messages, newConvoId) {
if (!Array.isArray(messages)) {
return [];
}
const idMap = new Map(); const idMap = new Map();
return messages.map((message) => { return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId); const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId); idMap.set(message.messageId, newMessageId);
const anonymizedMessage = Object.assign(message, { return {
...message,
messageId: newMessageId, messageId: newMessageId,
parentMessageId: parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId), idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId, conversationId: newConvoId,
}); model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) { : message.model,
anonymizedMessage.model = anonymizeAssistantId(); };
}
return anonymizedMessage;
}); });
} }
/**
* Retrieves shared messages for a given share ID
* @param {string} shareId - The share ID
* @returns {Promise<object|null>} The shared conversation data or null if not found
*/
async function getSharedMessages(shareId) { async function getSharedMessages(shareId) {
try { try {
const share = await SharedLink.findOne({ shareId }) const share = await SharedLink.findOne({ shareId, isPublic: true })
.populate({ .populate({
path: 'messages', path: 'messages',
select: '-_id -__v -user', select: '-_id -__v -user',
@ -84,165 +73,264 @@ async function getSharedMessages(shareId) {
.select('-_id -__v -user') .select('-_id -__v -user')
.lean(); .lean();
if (!share || !share.conversationId || !share.isPublic) { if (!share?.conversationId || !share.isPublic) {
return null; return null;
} }
const newConvoId = anonymizeConvoId(); const newConvoId = anonymizeConvoId(share.conversationId);
return Object.assign(share, { const result = {
...share,
conversationId: newConvoId, conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId), messages: anonymizeMessages(share.messages, newConvoId),
}); };
return result;
} catch (error) { } catch (error) {
logger.error('[getShare] Error getting share link', error); logger.error('[getShare] Error getting share link', {
throw new Error('Error getting share link'); error: error.message,
shareId,
});
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
} }
} }
/** async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
* Retrieves shared links for a user
* @param {string} user - The user ID
* @param {number} [pageNumber=1] - The page number
* @param {number} [pageSize=25] - The page size
* @param {boolean} [isPublic=true] - Whether to retrieve public links only
* @returns {Promise<object>} The shared links and pagination data
*/
async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) {
const query = { user, isPublic };
try { try {
const [totalConvos, sharedLinks] = await Promise.all([ const query = { user, isPublic };
SharedLink.countDocuments(query),
SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean(),
]);
const totalPages = Math.ceil((totalConvos || 1) / pageSize); if (pageParam) {
if (sortDirection === 'desc') {
query[sortBy] = { $lt: pageParam };
} else {
query[sortBy] = { $gt: pageParam };
}
}
if (search && search.trim()) {
try {
const searchResults = await Conversation.meiliSearch(search);
if (!searchResults?.hits?.length) {
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
query['conversationId'] = { $in: conversationIds };
} catch (searchError) {
logger.error('[getSharedLinks] Meilisearch error', {
error: searchError.message,
user,
});
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
}
const sort = {};
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
if (Array.isArray(query.conversationId)) {
query.conversationId = { $in: query.conversationId };
}
const sharedLinks = await SharedLink.find(query)
.sort(sort)
.limit(pageSize + 1)
.select('-__v -user')
.lean();
const hasNextPage = sharedLinks.length > pageSize;
const links = sharedLinks.slice(0, pageSize);
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
return { return {
sharedLinks, links: links.map((link) => ({
pages: totalPages, shareId: link.shareId,
pageNumber, title: link?.title || 'Untitled',
pageSize, isPublic: link.isPublic,
createdAt: link.createdAt,
conversationId: link.conversationId,
})),
nextCursor,
hasNextPage,
}; };
} catch (error) { } catch (error) {
logger.error('[getShareByPage] Error getting shares', error); logger.error('[getSharedLinks] Error getting shares', {
throw new Error('Error getting shares'); error: error.message,
} user,
}
/**
* Creates a new shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The created shared link
*/
async function createSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(share);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
}); });
} throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
const shareId = nanoid();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: true,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(newShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(newShare.messages, newConvoId),
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
} }
} }
/**
* Updates an existing shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data to update
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The updated shared link
*/
async function updateSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: false,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(updatedShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(updatedShare.messages, newConvoId),
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
}
/**
* Deletes a shared link
* @param {string} user - The user ID
* @param {object} params - The deletion parameters
* @param {string} params.shareId - The share ID to delete
* @returns {Promise<object>} The result of the deletion
*/
async function deleteSharedLink(user, { shareId }) {
try {
const result = await SharedLink.findOneAndDelete({ shareId, user });
return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' };
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
}
/**
* Deletes all shared links for a specific user
* @param {string} user - The user ID
* @returns {Promise<object>} The result of the deletion
*/
async function deleteAllSharedLinks(user) { async function deleteAllSharedLinks(user) {
try { try {
const result = await SharedLink.deleteMany({ user }); const result = await SharedLink.deleteMany({ user });
return { return {
message: 'All shared links have been deleted successfully', message: 'All shared links deleted successfully',
deletedCount: result.deletedCount, deletedCount: result.deletedCount,
}; };
} catch (error) { } catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error); logger.error('[deleteAllSharedLinks] Error deleting shared links', {
throw new Error('Error deleting shared links'); error: error.message,
user,
});
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
}
}
async function createSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
getMessages({ conversationId }),
]);
if (existingShare && existingShare.isPublic) {
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({ conversationId });
}
const conversation = await Conversation.findOne({ conversationId }).lean();
const title = conversation?.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
title,
user,
});
return { shareId, conversationId };
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
}
}
async function getSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
.select('shareId -_id')
.lean();
if (!share) {
return { shareId: null, success: false };
}
return { shareId: share.shareId, success: true };
} catch (error) {
logger.error('[getSharedLink] Error getting shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
}
}
async function updateSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
if (!share) {
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
}
const [updatedMessages] = await Promise.all([
getMessages({ conversationId: share.conversationId }),
]);
const newShareId = nanoid();
const update = {
messages: updatedMessages,
user,
shareId: newShareId,
};
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
new: true,
upsert: false,
runValidators: true,
}).lean();
if (!updatedShare) {
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
}
anonymizeConvo(updatedShare);
return { shareId: newShareId, conversationId: updatedShare.conversationId };
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError(
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
error.code || 'SHARE_UPDATE_ERROR',
);
}
}
async function deleteSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
return {
success: true,
shareId,
message: 'Share deleted successfully',
};
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
} }
} }
module.exports = { module.exports = {
SharedLink, SharedLink,
getSharedLink,
getSharedLinks, getSharedLinks,
createSharedLink, createSharedLink,
updateSharedLink, updateSharedLink,

View file

@ -20,14 +20,6 @@ const shareSchema = mongoose.Schema(
index: true, index: true,
}, },
isPublic: { isPublic: {
type: Boolean,
default: false,
},
isVisible: {
type: Boolean,
default: false,
},
isAnonymous: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },

View file

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { const {
getSharedLink,
getSharedMessages, getSharedMessages,
createSharedLink, createSharedLink,
updateSharedLink, updateSharedLink,
@ -45,29 +46,60 @@ if (allowSharedLinks) {
*/ */
router.get('/', requireJwtAuth, async (req, res) => { router.get('/', requireJwtAuth, async (req, res) => {
try { try {
let pageNumber = req.query.pageNumber || 1; const params = {
pageNumber = parseInt(pageNumber, 10); pageParam: req.query.cursor,
pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
isPublic: isEnabled(req.query.isPublic),
sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search
? decodeURIComponent(req.query.search.trim())
: undefined,
};
if (isNaN(pageNumber) || pageNumber < 1) { const result = await getSharedLinks(
return res.status(400).json({ error: 'Invalid page number' }); req.user.id,
} params.pageParam,
params.pageSize,
params.isPublic,
params.sortBy,
params.sortDirection,
params.search,
);
let pageSize = req.query.pageSize || 25; res.status(200).send({
pageSize = parseInt(pageSize, 10); links: result.links,
nextCursor: result.nextCursor,
if (isNaN(pageSize) || pageSize < 1) { hasNextPage: result.hasNextPage,
return res.status(400).json({ error: 'Invalid page size' }); });
}
const isPublic = req.query.isPublic === 'true';
res.status(200).send(await getSharedLinks(req.user.id, pageNumber, pageSize, isPublic));
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Error getting shared links' }); console.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
});
} }
}); });
router.post('/', requireJwtAuth, async (req, res) => { router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
try { try {
const created = await createSharedLink(req.user.id, req.body); const share = await getSharedLink(req.user.id, req.params.conversationId);
return res.status(200).json({
success: share.success,
shareId: share.shareId,
conversationId: req.params.conversationId,
});
} catch (error) {
res.status(500).json({ message: 'Error getting shared link' });
}
});
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try {
const created = await createSharedLink(req.user.id, req.params.conversationId);
if (created) { if (created) {
res.status(200).json(created); res.status(200).json(created);
} else { } else {
@ -78,11 +110,11 @@ router.post('/', requireJwtAuth, async (req, res) => {
} }
}); });
router.patch('/', requireJwtAuth, async (req, res) => { router.patch('/:shareId', requireJwtAuth, async (req, res) => {
try { try {
const updated = await updateSharedLink(req.user.id, req.body); const updatedShare = await updateSharedLink(req.user.id, req.params.shareId);
if (updated) { if (updatedShare) {
res.status(200).json(updated); res.status(200).json(updatedShare);
} else { } else {
res.status(404).end(); res.status(404).end();
} }
@ -93,14 +125,15 @@ router.patch('/', requireJwtAuth, async (req, res) => {
router.delete('/:shareId', requireJwtAuth, async (req, res) => { router.delete('/:shareId', requireJwtAuth, async (req, res) => {
try { try {
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId }); const result = await deleteSharedLink(req.user.id, req.params.shareId);
if (deleted) {
res.status(200).json(deleted); if (!result) {
} else { return res.status(404).json({ message: 'Share not found' });
res.status(404).end();
} }
return res.status(200).json(result);
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Error deleting shared link' }); return res.status(400).json({ message: error.message });
} }
}); });

View file

@ -72,6 +72,7 @@
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"match-sorter": "^6.3.4", "match-sorter": "^6.3.4",
"msedge-tts": "^1.3.4", "msedge-tts": "^1.3.4",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2", "rc-input-number": "^7.4.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.2", "react-avatar-editor": "^13.0.2",

View file

@ -141,7 +141,6 @@ function ConvoOptions({
/> />
{showShareDialog && ( {showShareDialog && (
<ShareButton <ShareButton
title={title ?? ''}
conversationId={conversationId ?? ''} conversationId={conversationId ?? ''}
open={showShareDialog} open={showShareDialog}
onOpenChange={setShowShareDialog} onOpenChange={setShowShareDialog}

View file

@ -1,112 +1,102 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { OGDialog } from '~/components/ui'; import { QRCodeSVG } from 'qrcode.react';
import { useToastContext } from '~/Providers'; import { Copy, CopyCheck } from 'lucide-react';
import type { TSharedLink } from 'librechat-data-provider'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import { useCreateSharedLinkMutation } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { Button, Spinner, OGDialog } from '~/components';
import SharedLinkButton from './SharedLinkButton'; import SharedLinkButton from './SharedLinkButton';
import { NotificationSeverity } from '~/common'; import { cn } from '~/utils';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
export default function ShareButton({ export default function ShareButton({
conversationId, conversationId,
title,
open, open,
onOpenChange, onOpenChange,
triggerRef, triggerRef,
children, children,
}: { }: {
conversationId: string; conversationId: string;
title: string;
open: boolean; open: boolean;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>; onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef?: React.RefObject<HTMLButtonElement>; triggerRef?: React.RefObject<HTMLButtonElement>;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const [showQR, setShowQR] = useState(false);
const { mutate, isLoading } = useCreateSharedLinkMutation(); const [sharedLink, setSharedLink] = useState('');
const [share, setShare] = useState<TSharedLink | null>(null); const [isCopying, setIsCopying] = useState(false);
const [isUpdated, setIsUpdated] = useState(false); const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const [isNewSharedLink, setIsNewSharedLink] = useState(false); const copyLink = useCopyToClipboard({ text: sharedLink });
useEffect(() => { useEffect(() => {
if (!open && triggerRef && triggerRef.current) { if (share?.shareId !== undefined) {
triggerRef.current.focus(); const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
setSharedLink(link);
} }
}, [open, triggerRef]); }, [share]);
useEffect(() => { const button =
if (isLoading || share) { isLoading === true ? null : (
return;
}
const data = {
conversationId,
title,
isAnonymous: true,
};
mutate(data, {
onSuccess: (result) => {
setShare(result);
setIsNewSharedLink(!result.isPublic);
},
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
// mutation.mutate should only be called once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!conversationId) {
return null;
}
const buttons = share && (
<SharedLinkButton <SharedLinkButton
share={share} share={share}
conversationId={conversationId} conversationId={conversationId}
setShare={setShare} setShareDialogOpen={onOpenChange}
isUpdated={isUpdated} showQR={showQR}
setIsUpdated={setIsUpdated} setShowQR={setShowQR}
setSharedLink={setSharedLink}
/> />
); );
const shareId = share?.shareId ?? '';
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}> <OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children} {children}
<OGDialogTemplate <OGDialogTemplate
buttons={buttons} buttons={button}
showCloseButton={true} showCloseButton={true}
showCancelButton={false} showCancelButton={false}
title={localize('com_ui_share_link_to_chat')} title={localize('com_ui_share_link_to_chat')}
className="max-w-[550px]" className="max-w-[550px]"
main={ main={
<div> <div>
<div className="h-full py-2 text-gray-400 dark:text-gray-200"> <div className="h-full py-2 text-text-primary">
{(() => { {(() => {
if (isLoading) { if (isLoading === true) {
return <Spinner className="m-auto h-14 animate-spin" />; return <Spinner className="m-auto h-14 animate-spin" />;
} }
if (isUpdated) { return share?.success === true
return isNewSharedLink
? localize('com_ui_share_created_message')
: localize('com_ui_share_updated_message');
}
return share?.isPublic === true
? localize('com_ui_share_update_message') ? localize('com_ui_share_update_message')
: localize('com_ui_share_create_message'); : localize('com_ui_share_create_message');
})()} })()}
</div> </div>
<div className="relative items-center rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
</div>
)}
{shareId && (
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
<div className="flex-1 break-all text-sm text-text-secondary">{sharedLink}</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (isCopying) {
return;
}
copyLink(setIsCopying);
}}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
)}
</div>
</div> </div>
} }
/> />

View file

@ -1,31 +1,38 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import copy from 'copy-to-clipboard'; import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import { Copy, Link } from 'lucide-react'; import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import type { TSharedLink } from 'librechat-data-provider'; import {
import { useUpdateSharedLinkMutation } from '~/data-provider'; useCreateSharedLinkMutation,
useUpdateSharedLinkMutation,
useDeleteSharedLinkMutation,
} from '~/data-provider';
import { Button, OGDialog, Spinner, TooltipAnchor, Label } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
export default function SharedLinkButton({ export default function SharedLinkButton({
conversationId,
share, share,
setShare, conversationId,
isUpdated, setShareDialogOpen,
setIsUpdated, showQR,
setShowQR,
setSharedLink,
}: { }: {
share: TSharedLinkGetResponse | undefined;
conversationId: string; conversationId: string;
share: TSharedLink; setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
setShare: (share: TSharedLink) => void; showQR: boolean;
isUpdated: boolean; setShowQR: (showQR: boolean) => void;
setIsUpdated: (isUpdated: boolean) => void; setSharedLink: (sharedLink: string) => void;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [isCopying, setIsCopying] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const shareId = share?.shareId ?? '';
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation({ const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
onError: () => { onError: () => {
showToast({ showToast({
message: localize('com_ui_share_error'), message: localize('com_ui_share_error'),
@ -35,92 +42,145 @@ export default function SharedLinkButton({
}, },
}); });
const copyLink = () => { const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
if (!share) { onError: () => {
return; showToast({
} message: localize('com_ui_share_error'),
setIsCopying(true); severity: NotificationSeverity.ERROR,
const sharedLink = showIcon: true,
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId; });
copy(sharedLink); },
setTimeout(() => {
setIsCopying(false);
}, 1500);
};
const updateSharedLink = async () => {
if (!share) {
return;
}
const result = await mutateAsync({
shareId: share.shareId,
conversationId: conversationId,
isPublic: true,
isVisible: true,
isAnonymous: true,
}); });
if (result) { const deleteMutation = useDeleteSharedLinkMutation({
setShare(result); onSuccess: async () => {
setIsUpdated(true); setShowDeleteDialog(false);
copyLink(); setShareDialogOpen(false);
}
};
const getHandler = () => {
if (isUpdated) {
return {
handler: () => {
copyLink();
}, },
label: ( onError: (error) => {
<> console.error('Delete error:', error);
<Copy className="mr-2 h-4 w-4" /> showToast({
{localize('com_ui_copy_link')} message: localize('com_ui_share_delete_error'),
</> severity: NotificationSeverity.ERROR,
), });
};
}
if (share.isPublic) {
return {
handler: async () => {
await updateSharedLink();
}, },
});
label: ( const generateShareLink = useCallback((shareId: string) => {
<> return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
<Link className="mr-2 h-4 w-4" /> }, []);
{localize('com_ui_update_link')}
</> const updateSharedLink = async () => {
), if (!shareId) {
}; return;
}
const updateShare = await mutateAsync({ shareId });
const newLink = generateShareLink(updateShare.shareId);
setSharedLink(newLink);
};
const createShareLink = async () => {
const share = await mutate({ conversationId });
const newLink = generateShareLink(share.shareId);
setSharedLink(newLink);
};
const handleDelete = async () => {
if (!shareId) {
return;
}
try {
await deleteMutation.mutateAsync({ shareId });
showToast({
message: localize('com_ui_shared_link_delete_success'),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared link:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
} }
return {
handler: updateSharedLink,
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_create_link')}
</>
),
};
}; };
const handlers = getHandler();
return ( return (
<button
disabled={isLoading || isCopying}
onClick={() => {
handlers.handler();
}}
className="btn btn-primary flex items-center justify-center"
>
{isCopying && (
<> <>
<Copy className="mr-2 h-4 w-4" /> <div className="flex gap-2">
{localize('com_ui_copied')} {!shareId && (
</> <Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
{!isCreateLoading && localize('com_ui_create_link')}
{isCreateLoading && <Spinner className="size-4" />}
</Button>
)} )}
{!isCopying && !isLoading && handlers.label} {shareId && (
{!isCopying && isLoading && <Spinner className="h-4 w-4" />} <div className="flex items-center gap-2">
</button> <TooltipAnchor
description={localize('com_ui_refresh_link')}
render={(props) => (
<Button
{...props}
onClick={() => updateSharedLink()}
variant="outline"
disabled={isUpdateLoading}
>
{isUpdateLoading ? (
<Spinner className="size-4" />
) : (
<RotateCw className="size-4" />
)}
</Button>
)}
/>
<TooltipAnchor
description={showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr')}
render={(props) => (
<Button {...props} onClick={() => setShowQR(!showQR)} variant="outline">
<QrCode className="size-4" />
</Button>
)}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={(props) => (
<Button {...props} onClick={() => setShowDeleteDialog(true)} variant="destructive">
<Trash2 className="size-4" />
</Button>
)}
/>
</div>
)}
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>&quot;{shareId}&quot;</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
</>
); );
} }

View file

@ -1,198 +0,0 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import {
Button,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TooltipAnchor,
Skeleton,
Spinner,
OGDialog,
OGDialogTrigger,
} from '~/components';
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
const [isDeleting, setIsDeleting] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const mutation = useDeleteSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
setIsDeleting(false);
},
});
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
if (mutation.isLoading) {
return;
}
setIsDeleting(true);
await mutation.mutateAsync({ shareId });
setIsDeleting(false);
};
return (
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
<TableCell>
<Link
to={`/share/${sharedLink.shareId}`}
target="_blank"
rel="noreferrer"
className="flex items-center text-blue-500 hover:underline"
>
<LinkIcon className="mr-2 h-4 w-4" />
{sharedLink.title}
</Link>
</TableCell>
<TableCell>
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</TableCell>
<TableCell className="text-right">
{sharedLink.conversationId && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
aria-label="Delete shared link"
variant="ghost"
size="icon"
className="size-8"
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: () => confirmDelete(sharedLink.shareId),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</TableCell>
</TableRow>
);
}
export default function ShareLinkTable({ className }) {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading } =
useSharedLinksInfiniteQuery({ pageNumber: '1', isPublic: true }, { enabled: isAuthenticated });
const { containerRef } = useNavScrolling<SharedLinksResponse>({
setShowLoading,
hasNextPage: hasNextPage,
fetchNextPage: fetchNextPage,
isFetchingNextPage: isFetchingNextPage,
});
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
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) {
return <div className="text-gray-300">{skeletons}</div>;
}
if (isError) {
return (
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
{localize('com_ui_share_retrieve_error')}
</div>
);
}
if (sharedLinks.length === 0) {
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
}
return (
<div
className={cn(
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
className,
)}
ref={containerRef}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sharedLinks.map((sharedLink) => (
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
))}
</TableBody>
</Table>
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
</div>
);
}

View file

@ -1,27 +1,324 @@
import { useLocalize } from '~/hooks'; import { useCallback, useState, useMemo, useEffect } from 'react';
import { OGDialog, OGDialogTrigger } from '~/components/ui'; import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce';
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTrigger,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Button,
TooltipAnchor,
Label,
} from '~/components/ui';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useMediaQuery } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import { Spinner } from '~/components/svg';
import ShareLinkTable from './SharedLinkTable'; const PAGE_SIZE = 25;
const DEFAULT_PARAMS: SharedLinksListParams = {
pageSize: PAGE_SIZE,
isPublic: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',
};
export default function SharedLinks() { export default function SharedLinks() {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useSharedLinksQuery(queryParams, {
enabled: isOpen,
staleTime: 0,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
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],
);
useEffect(() => {
return () => {
debouncedFilterChange.cancel();
};
}, [debouncedFilterChange]);
const allLinks = useMemo(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => page.links.filter(Boolean));
}, [data?.pages]);
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
setDeleteRow(null);
await refetch();
},
onError: (error) => {
console.error('Delete error:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const handleDelete = useCallback(
async (selectedRows: SharedLinkItem[]) => {
const validRows = selectedRows.filter(
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
);
if (validRows.length === 0) {
showToast({
message: localize('com_ui_no_valid_items'),
severity: NotificationSeverity.WARNING,
});
return;
}
try {
for (const row of validRows) {
await deleteMutation.mutateAsync({ shareId: row.shareId });
}
showToast({
message: localize(
validRows.length === 1
? 'com_ui_shared_link_delete_success'
: 'com_ui_shared_link_bulk_delete_success',
),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared links:', error);
showToast({
message: localize('com_ui_bulk_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
},
[deleteMutation, showToast, localize],
);
const handleFetchNextPage = useCallback(async () => {
if (hasNextPage !== true || isFetchingNextPage) {
return;
}
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
}
setIsDeleteOpen(false);
}, [deleteRow, handleDelete]);
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: ({ column }) => {
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', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
>
{localize('com_ui_name')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => {
const { title, shareId } = row.original;
return (
<div className="flex items-center gap-2">
<Link
to={`/share/${shareId}`}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-blue-500 hover:underline"
title={title}
>
{title}
</Link>
</div>
);
},
meta: {
size: '35%',
mobileSize: '50%',
},
},
{
accessorKey: 'createdAt',
header: ({ column }) => {
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', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_date')}
<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: '10%',
mobileSize: '20%',
},
},
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
meta: {
size: '7%',
mobileSize: '25%',
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_view_source')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
title={localize('com_ui_view_source')}
>
<MessageSquare className="size-4" />
</Button>
}
></TooltipAnchor>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</div>
),
},
],
[isSmallScreen, localize],
);
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div> <div>{localize('com_nav_shared_links')}</div>
<OGDialog> <OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild> <OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<button className="btn btn-neutral relative "> <button className="btn btn-neutral relative">
{localize('com_nav_shared_links_manage')} {localize('com_nav_shared_links_manage')}
</button> </button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent
title={localize('com_nav_my_files')}
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
</OGDialogHeader>
<DataTable
columns={columns}
data={allLinks}
onDelete={handleDelete}
filterColumn="title"
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={handleFetchNextPage}
showCheckboxes={false}
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate <OGDialogTemplate
title={localize('com_nav_shared_links')} showCloseButton={false}
className="max-w-[1000px]" title={localize('com_ui_delete_shared_link')}
showCancelButton={false} className="max-w-[450px]"
main={<ShareLinkTable className="w-full" />} main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/> />
</OGDialog> </OGDialog>
</div> </div>

View file

@ -140,9 +140,9 @@ const AdminSettings = () => {
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
size={'sm'} size='sm'
variant={'outline'} variant='outline'
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent" className="h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary"
> >
<ShieldEllipsis className="cursor-pointer" /> <ShieldEllipsis className="cursor-pointer" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span> <span className="hidden sm:flex">{localize('com_ui_admin')}</span>

View file

@ -1,6 +1,6 @@
import { Trash2 } from 'lucide-react';
import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const DeleteVersion = ({ const DeleteVersion = ({
@ -18,14 +18,15 @@ const DeleteVersion = ({
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
size={'sm'} variant="default"
className="h-10 w-10 border border-transparent bg-red-600 text-red-500 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800" size="sm"
className="h-10 w-10 border border-transparent bg-red-600 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800 p-0.5"
disabled={disabled} disabled={disabled}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
> >
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" /> <Trash2 className="cursor-pointer text-white size-5" />
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogTemplate <OGDialogTemplate

View file

@ -256,9 +256,9 @@ const PromptForm = () => {
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />} {hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && ( {editorMode === PromptsEditorMode.ADVANCED && (
<Button <Button
size={'sm'} variant="default"
className="h-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600" size="sm"
variant={'default'} className="h-10 w-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600 p-0.5"
onClick={() => { onClick={() => {
const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt); const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt);
makeProductionMutation.mutate( makeProductionMutation.mutate(
@ -283,7 +283,7 @@ const PromptForm = () => {
makeProductionMutation.isLoading makeProductionMutation.isLoading
} }
> >
<Rocket className="cursor-pointer text-white" /> <Rocket className="cursor-pointer text-white size-5" />
</Button> </Button>
)} )}
<DeleteConfirm <DeleteConfirm

View file

@ -80,16 +80,18 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
variant={'default'} variant="default"
size={'sm'} size="sm"
className="h-10 w-10 border border-transparent bg-blue-500/90 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800" className="h-10 w-10 border border-transparent bg-blue-500/90 p-0.5 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
disabled={disabled} disabled={disabled}
> >
<Share2Icon className="cursor-pointer text-white " /> <Share2Icon className="size-5 cursor-pointer text-white" />
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary-alt text-text-secondary"> <OGDialogContent className="w-11/12 max-w-[600px]">
<OGDialogTitle>{localize('com_ui_share_var', `"${group.name}"`)}</OGDialogTitle> <OGDialogTitle className="truncate pr-2" title={group.name}>
{localize('com_ui_share_var', `"${group.name}"`)}
</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}> <form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex items-center justify-between gap-2 py-4"> <div className="mb-4 flex items-center justify-between gap-2 py-4">
<div className="flex items-center"> <div className="flex items-center">

View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placeholder }) => {
const [isFocused, setIsFocused] = useState(false);
const isSearching = searching === true;
return (
<div className="relative w-full">
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
{/* Background gradient effect */}
<div
className={`
absolute inset-0 rounded-lg
bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20
transition-all duration-500 ease-in-out
${isSearching ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'}
`}
/>
<div className="relative">
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
<Search
className={`
h-4 w-4 transition-all duration-500 ease-in-out
${isFocused ? 'text-blue-500' : 'text-gray-400'}
${isSearching ? 'text-blue-400' : ''}
`}
/>
</div>
{/* Input field with background transitions */}
<input
type="text"
value={value}
onChange={onChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className={`
w-full rounded-lg px-10 py-2
transition-all duration-500 ease-in-out
placeholder:text-gray-400
focus:outline-none focus:ring-0
${isFocused ? 'bg-white/10' : 'bg-white/5'}
${isSearching ? 'bg-white/15' : ''}
backdrop-blur-sm
`}
/>
{/* Animated loading indicator */}
<div
className={`
absolute right-3 top-1/2 -translate-y-1/2
transition-all duration-500 ease-in-out
${isSearching ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
`}
>
<div className="relative h-2 w-2">
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
<div className="absolute inset-0 rounded-full bg-blue-500" />
</div>
</div>
</div>
</div>
{/* Outer glow effect */}
<div
className={`
absolute -inset-8 -z-10
transition-all duration-700 ease-in-out
${isSearching ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
`}
>
<div className="absolute inset-0">
<div
className={`
bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent
transition-opacity duration-700 ease-in-out
${isSearching ? 'animate-pulse-slow opacity-100' : 'opacity-0'}
`}
/>
<div
className={`
absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5
blur-xl transition-all duration-700 ease-in-out
${isSearching ? 'animate-gradient-x opacity-100' : 'opacity-0'}
`}
/>
</div>
</div>
{/* Focus state background glow */}
<div
className={`
absolute inset-0 -z-20 bg-gradient-to-r from-blue-500/10
via-purple-500/10 to-blue-500/10 blur-xl
transition-all duration-500 ease-in-out
${isFocused ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
`}
/>
</div>
);
};
export default AnimatedSearchInput;

View file

@ -15,7 +15,8 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
submit: 'bg-surface-submit text-text-primary hover:bg-surface-submit/90', // hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',

View file

@ -0,0 +1,459 @@
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
ColumnDef,
flexRender,
SortingState,
useReactTable,
getCoreRowModel,
VisibilityState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table';
import type { Table as TTable } from '@tanstack/react-table';
import {
Button,
Table,
Checkbox,
TableRow,
TableBody,
TableCell,
TableHead,
TableHeader,
AnimatedSearchInput,
} from './';
import { TrashIcon, Spinner } from '~/components/svg';
import { useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
};
};
const SelectionCheckbox = memo(
({
checked,
onChange,
ariaLabel,
}: {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
}) => (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => e.stopPropagation()}
className="flex h-full w-[30px] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
</div>
),
);
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface DataTableProps<TData, TValue> {
columns: TableColumn<TData, TValue>[];
data: TData[];
onDelete?: (selectedRows: TData[]) => Promise<void>;
filterColumn?: string;
defaultSort?: SortingState;
columnVisibilityMap?: Record<string, string>;
className?: string;
pageSize?: number;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: (options?: unknown) => Promise<unknown>;
enableRowSelection?: boolean;
showCheckboxes?: boolean;
onFilterChange?: (value: string) => void;
filterValue?: string;
}
const TableRowComponent = <TData, TValue>({
row,
isSmallScreen,
onSelectionChange,
index,
isSearching,
}: {
row: Row<TData>;
isSmallScreen: boolean;
onSelectionChange?: (rowId: string, selected: boolean) => void;
index: number;
isSearching: boolean;
}) => {
const handleSelection = useCallback(
(value: boolean) => {
row.toggleSelected(value);
onSelectionChange?.(row.id, value);
},
[row, onSelectionChange],
);
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={`
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={{
animationDelay: `${index * 20}ms`,
transform: `translateY(${isSearching ? '4px' : '0'})`,
}}
>
{row.getVisibleCells().map((cell) => {
if (cell.column.id === 'select') {
return (
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={handleSelection}
ariaLabel="Select row"
/>
</TableCell>
);
}
return (
<TableCell
key={cell.id}
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
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
`}
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="overflow-hidden text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
);
};
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
function getColumnStyle<TData, TValue>(
column: TableColumn<TData, TValue>,
isSmallScreen: boolean,
): React.CSSProperties {
return {
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
minWidth: column.meta?.minWidth,
maxWidth: column.meta?.size,
};
}
const DeleteButton = memo(
({
onDelete,
isDeleting,
disabled,
isSmallScreen,
localize,
}: {
onDelete?: () => Promise<void>;
isDeleting: boolean;
disabled: boolean;
isSmallScreen: boolean;
localize: (key: string) => string;
}) => {
if (!onDelete) {
return null;
}
return (
<Button
variant="outline"
onClick={onDelete}
disabled={disabled}
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
>
{isDeleting ? (
<Spinner className="size-4" />
) : (
<>
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
</>
)}
</Button>
);
},
);
export default function DataTable<TData, TValue>({
columns,
data,
onDelete,
filterColumn,
defaultSort = [],
className = '',
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
enableRowSelection = true,
showCheckboxes = true,
onFilterChange,
filterValue,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
const [isSearching, setIsSearching] = useState(false);
const tableColumns = useMemo(() => {
if (!enableRowSelection || !showCheckboxes) {
return columns;
}
const selectColumn = {
id: 'select',
header: ({ table }: { table: TTable<TData> }) => (
<div className="flex h-full w-[30px] items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label="Select all"
/>
</div>
),
cell: ({ row }: { row: Row<TData> }) => (
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={(value) => row.toggleSelected(value)}
ariaLabel="Select row"
/>
),
meta: { size: '50px' },
};
return [selectColumn, ...columns];
}, [columns, enableRowSelection, showCheckboxes]);
const table = useReactTable({
data,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: useCallback(() => 48, []),
overscan: 10,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
useEffect(() => {
const scrollElement = tableContainerRef.current;
if (!scrollElement) {
return;
}
const handleScroll = async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
try {
// Safely fetch next page without breaking if lastPage is undefined
await fetchNextPage?.();
} catch (error) {
console.error('Unable to fetch next page:', error);
}
}
};
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
setIsSearching(true);
const timeout = setTimeout(() => {
onFilterChange?.(searchTerm);
setIsSearching(false);
}, 300);
return () => clearTimeout(timeout);
}, [searchTerm, onFilterChange]);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
}
setIsDeleting(true);
try {
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
await onDelete(itemsToDelete);
setRowSelection({});
// await fetchNextPage?.({ pageParam: lastPage?.nextCursor });
} finally {
setIsDeleting(false);
}
}, [onDelete, table]);
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
{enableRowSelection && showCheckboxes && (
<DeleteButton
onDelete={handleDelete}
isDeleting={isDeleting}
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
isSmallScreen={isSmallScreen}
localize={localize}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
isSearching={isSearching}
placeholder={`${localize('com_ui_search')}...`}
/>
</div>
)}
</div>
{/* Virtualized table */}
<div
ref={tableContainerRef}
className={cn(
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'transition-all duration-300 ease-out',
isSearching && 'bg-surface-secondary/50',
className,
)}
>
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<MemoizedTableRow
key={row.id}
row={row}
isSmallScreen={isSmallScreen}
index={virtualRow.index}
isSearching={isSearching}
/>
);
})}
{!virtualRows.length && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4 text-center">
{localize('com_ui_no_data')}
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
{/* Loading indicator */}
{(isFetchingNextPage || hasNextPage) && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4">
<div className="flex h-full items-center justify-center">
{isFetchingNextPage ? (
<Spinner className="size-4" />
) : (
hasNextPage && <div className="h-6" />
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import {
OGDialogHeader, OGDialogHeader,
OGDialogContent, OGDialogContent,
OGDialogDescription, OGDialogDescription,
} from './'; } from './OriginalDialog';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils/'; import { cn } from '~/utils/';

View file

@ -33,6 +33,8 @@ export { default as ThemeSelector } from './ThemeSelector';
export { default as SelectDropDown } from './SelectDropDown'; export { default as SelectDropDown } from './SelectDropDown';
export { default as MultiSelectPop } from './MultiSelectPop'; export { default as MultiSelectPop } from './MultiSelectPop';
export { default as ModelParameters } from './ModelParameters'; export { default as ModelParameters } from './ModelParameters';
export { default as OGDialogTemplate } from './OGDialogTemplate';
export { default as InputWithDropdown } from './InputWithDropDown'; export { default as InputWithDropdown } from './InputWithDropDown';
export { default as SelectDropDownPop } from './SelectDropDownPop'; export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
export { default as MultiSelectDropDown } from './MultiSelectDropDown'; export { default as MultiSelectDropDown } from './MultiSelectDropDown';

View file

@ -1,26 +1,18 @@
import { import {
Constants, Constants,
InfiniteCollections,
defaultAssistantsVersion, defaultAssistantsVersion,
ConversationListResponse, ConversationListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 * as t 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 { useConversationTagsQuery, useConversationsInfiniteQuery } from './queries';
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo'; import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
import { updateConversationTag } from '~/utils/conversationTags'; import { updateConversationTag } from '~/utils/conversationTags';
import { normalizeData } from '~/utils/collection'; import { normalizeData } from '~/utils/collection';
import {
useConversationTagsQuery,
useConversationsInfiniteQuery,
useSharedLinksInfiniteQuery,
} from './queries';
import { import {
logger, logger,
/* Shared Links */
addSharedLink,
deleteSharedLink,
/* Conversations */ /* Conversations */
addConversation, addConversation,
updateConvoFields, updateConvoFields,
@ -244,120 +236,126 @@ export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => {
}; };
export const useCreateSharedLinkMutation = ( export const useCreateSharedLinkMutation = (
options?: t.CreateSharedLinkOptions, options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => { ): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery();
const { onSuccess, ..._options } = options || {}; const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TSharedLinkRequest) => dataService.createSharedLink(payload), { return useMutation(
onSuccess: (_data, vars, context) => { ({ conversationId }: { conversationId: string }) => {
if (!vars.conversationId) { if (!conversationId) {
return; throw new Error('Conversation ID is required');
} }
const isPublic = vars.isPublic === true; return dataService.createSharedLink(conversationId);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => {
if (!sharedLink) {
return sharedLink;
}
const pageSize = sharedLink.pages[0].pageSize as number;
return normalizeData(
// If the shared link is public, add it to the shared links cache list
isPublic ? addSharedLink(sharedLink, _data) : deleteSharedLink(sharedLink, _data.shareId),
InfiniteCollections.SHARED_LINKS,
pageSize,
);
});
queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data);
if (!isPublic) {
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1,
});
}
onSuccess?.(_data, vars, context); onSuccess?.(_data, vars, context);
}, },
..._options, ..._options,
}); },
);
}; };
export const useUpdateSharedLinkMutation = ( export const useUpdateSharedLinkMutation = (
options?: t.UpdateSharedLinkOptions, options?: t.MutationOptions<t.TUpdateShareLinkRequest, { shareId: string }>,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => { ): UseMutationResult<t.TSharedLinkResponse, unknown, { shareId: string }, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery();
const { onSuccess, ..._options } = options || {}; const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TSharedLinkRequest) => dataService.updateSharedLink(payload), { return useMutation(
onSuccess: (_data, vars, context) => { ({ shareId }) => {
if (!vars.conversationId) { if (!shareId) {
return; throw new Error('Share ID is required');
}
const isPublic = vars.isPublic === true;
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => {
if (!sharedLink) {
return sharedLink;
}
return normalizeData(
// If the shared link is public, add it to the shared links cache list.
isPublic
? // Even if the SharedLink data exists in the database, it is not registered in the cache when isPublic is false.
// Therefore, when isPublic is true, use addSharedLink instead of updateSharedLink.
addSharedLink(sharedLink, _data)
: deleteSharedLink(sharedLink, _data.shareId),
InfiniteCollections.SHARED_LINKS,
sharedLink.pages[0].pageSize as number,
);
});
queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data);
if (!isPublic) {
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1,
});
} }
return dataService.updateSharedLink(shareId);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
onSuccess?.(_data, vars, context); onSuccess?.(_data, vars, context);
}, },
..._options, ..._options,
}); },
);
}; };
export const useDeleteSharedLinkMutation = ( export const useDeleteSharedLinkMutation = (
options?: t.DeleteSharedLinkOptions, options?: t.DeleteSharedLinkOptions,
): UseMutationResult<t.TDeleteSharedLinkResponse, unknown, { shareId: string }, unknown> => { ): UseMutationResult<
t.TDeleteSharedLinkResponse,
unknown,
{ shareId: string },
t.DeleteSharedLinkContext
> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery(); const { onSuccess } = options || {};
const { onSuccess, ..._options } = options || {};
return useMutation(({ shareId }) => dataService.deleteSharedLink(shareId), { return useMutation((vars) => dataService.deleteSharedLink(vars.shareId), {
onSuccess: (_data, vars, context) => { onMutate: async (vars) => {
if (!vars.shareId) { await queryClient.cancelQueries({
return; queryKey: [QueryKeys.sharedLinks],
exact: false,
});
const previousQueries = new Map();
const queryKeys = queryClient.getQueryCache().findAll([QueryKeys.sharedLinks]);
queryKeys.forEach((query) => {
const previousData = queryClient.getQueryData(query.queryKey);
previousQueries.set(query.queryKey, previousData);
queryClient.setQueryData<t.SharedLinkQueryData>(query.queryKey, (old) => {
if (!old?.pages) {
return old;
} }
queryClient.setQueryData([QueryKeys.sharedMessages, vars.shareId], null); const updatedPages = old.pages.map((page) => ({
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (data) => { ...page,
if (!data) { links: page.links.filter((link) => link.shareId !== vars.shareId),
return data; }));
}
return normalizeData( const nonEmptyPages = updatedPages.filter((page) => page.links.length > 0);
deleteSharedLink(data, vars.shareId),
InfiniteCollections.SHARED_LINKS, return {
data.pages[0].pageSize as number, ...old,
); pages: nonEmptyPages,
};
}); });
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1,
}); });
onSuccess?.(_data, vars, context);
return { previousQueries };
},
onError: (_err, _vars, context) => {
if (context?.previousQueries) {
context.previousQueries.forEach((prevData: unknown, prevQueryKey: unknown) => {
queryClient.setQueryData(prevQueryKey as string[], prevData);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: [QueryKeys.sharedLinks],
exact: false,
});
},
onSuccess: (data, variables) => {
if (onSuccess) {
onSuccess(data, variables);
}
queryClient.refetchQueries({
queryKey: [QueryKeys.sharedLinks],
exact: true,
});
}, },
..._options,
}); });
}; };
@ -575,9 +573,7 @@ export const useDuplicateConversationMutation = (
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => { ): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ..._options } = options ?? {}; const { onSuccess, ..._options } = options ?? {};
return useMutation( return useMutation((payload) => dataService.duplicateConversation(payload), {
(payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload),
{
onSuccess: (data, vars, context) => { onSuccess: (data, vars, context) => {
const originalId = vars.conversationId ?? ''; const originalId = vars.conversationId ?? '';
if (originalId.length === 0) { if (originalId.length === 0) {
@ -603,8 +599,7 @@ export const useDuplicateConversationMutation = (
onSuccess?.(data, vars, context); onSuccess?.(data, vars, context);
}, },
..._options, ..._options,
}, });
);
}; };
export const useForkConvoMutation = ( export const useForkConvoMutation = (

View file

@ -24,7 +24,7 @@ import type {
AssistantDocument, AssistantDocument,
TEndpointsConfig, TEndpointsConfig,
TCheckUserKeyResponse, TCheckUserKeyResponse,
SharedLinkListParams, SharedLinksListParams,
SharedLinksResponse, SharedLinksResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { findPageForConversation } from '~/utils'; import { findPageForConversation } from '~/utils';
@ -139,31 +139,29 @@ export const useConversationsInfiniteQuery = (
); );
}; };
export const useSharedLinksInfiniteQuery = ( export const useSharedLinksQuery = (
params?: SharedLinkListParams, params: SharedLinksListParams,
config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>, config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>,
) => { ) => {
return useInfiniteQuery<SharedLinksResponse, unknown>( const { pageSize, isPublic, search, sortBy, sortDirection } = params;
[QueryKeys.sharedLinks],
({ pageParam = '' }) => return useInfiniteQuery<SharedLinksResponse>({
queryKey: [QueryKeys.sharedLinks, { pageSize, isPublic, search, sortBy, sortDirection }],
queryFn: ({ pageParam }) =>
dataService.listSharedLinks({ dataService.listSharedLinks({
...params, cursor: pageParam?.toString(),
pageNumber: pageParam?.toString(), pageSize,
isPublic: params?.isPublic || true, isPublic,
search,
sortBy,
sortDirection,
}), }),
{ 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
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config, ...config,
}, });
);
}; };
export const useConversationTagsQuery = ( export const useConversationTagsQuery = (

View file

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { ContentTypes } from 'librechat-data-provider'; import { ContentTypes } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
@ -7,8 +7,20 @@ export default function useCopyToClipboard({
text, text,
content, content,
}: Partial<Pick<TMessage, 'text' | 'content'>>) { }: Partial<Pick<TMessage, 'text' | 'content'>>) {
const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const copyToClipboard = useCallback( const copyToClipboard = useCallback(
(setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => { (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
setIsCopied(true); setIsCopied(true);
let messageText = text ?? ''; let messageText = text ?? '';
if (content) { if (content) {
@ -22,7 +34,7 @@ export default function useCopyToClipboard({
} }
copy(messageText, { format: 'text/plain' }); copy(messageText, { format: 'text/plain' });
setTimeout(() => { copyTimeoutRef.current = setTimeout(() => {
setIsCopied(false); setIsCopied(false);
}, 3000); }, 3000);
}, },

View file

@ -52,7 +52,6 @@ export default {
com_ui_chats: 'الدردشات', com_ui_chats: 'الدردشات',
com_ui_share: 'مشاركة', com_ui_share: 'مشاركة',
com_ui_copy_link: 'نسخ الرابط', com_ui_copy_link: 'نسخ الرابط',
com_ui_update_link: 'رابط التحديث',
com_ui_create_link: 'إنشاء رابط', com_ui_create_link: 'إنشاء رابط',
com_ui_share_link_to_chat: 'شارك الرابط في الدردشة', com_ui_share_link_to_chat: 'شارك الرابط في الدردشة',
com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة', com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة',

View file

@ -293,7 +293,6 @@ export default {
com_ui_share_var: 'Compartilhar {0}', com_ui_share_var: 'Compartilhar {0}',
com_ui_enter_var: 'Inserir {0}', com_ui_enter_var: 'Inserir {0}',
com_ui_copy_link: 'Copiar link', com_ui_copy_link: 'Copiar link',
com_ui_update_link: 'Atualizar link',
com_ui_create_link: 'Criar link', com_ui_create_link: 'Criar link',
com_ui_share_to_all_users: 'Compartilhar com todos os usuários', com_ui_share_to_all_users: 'Compartilhar com todos os usuários',
com_ui_my_prompts: 'Meus Prompts', com_ui_my_prompts: 'Meus Prompts',

View file

@ -260,7 +260,6 @@ export default {
com_ui_share: 'Teilen', com_ui_share: 'Teilen',
com_ui_share_var: '{0} teilen', com_ui_share_var: '{0} teilen',
com_ui_copy_link: 'Link kopieren', com_ui_copy_link: 'Link kopieren',
com_ui_update_link: 'Link aktualisieren',
com_ui_create_link: 'Link erstellen', com_ui_create_link: 'Link erstellen',
com_ui_share_to_all_users: 'Mit allen Benutzern teilen', com_ui_share_to_all_users: 'Mit allen Benutzern teilen',
com_ui_my_prompts: 'Meine Prompts', com_ui_my_prompts: 'Meine Prompts',

View file

@ -53,7 +53,7 @@ export default {
com_download_expired: '(download expired)', com_download_expired: '(download expired)',
com_download_expires: '(click here to download - expires {0})', com_download_expires: '(click here to download - expires {0})',
com_click_to_download: '(click here to download)', com_click_to_download: '(click here to download)',
com_files_number_selected: '{0} of {1} file(s) selected', com_files_number_selected: '{0} of {1} items(s) selected',
com_sidepanel_select_assistant: 'Select an Assistant', com_sidepanel_select_assistant: 'Select an Assistant',
com_sidepanel_parameters: 'Parameters', com_sidepanel_parameters: 'Parameters',
com_sidepanel_assistant_builder: 'Assistant Builder', com_sidepanel_assistant_builder: 'Assistant Builder',
@ -354,7 +354,6 @@ export default {
com_ui_share_var: 'Share {0}', com_ui_share_var: 'Share {0}',
com_ui_enter_var: 'Enter {0}', com_ui_enter_var: 'Enter {0}',
com_ui_copy_link: 'Copy link', com_ui_copy_link: 'Copy link',
com_ui_update_link: 'Update link',
com_ui_create_link: 'Create link', com_ui_create_link: 'Create link',
com_ui_share_to_all_users: 'Share to all users', com_ui_share_to_all_users: 'Share to all users',
com_ui_my_prompts: 'My Prompts', com_ui_my_prompts: 'My Prompts',
@ -379,6 +378,8 @@ export default {
com_ui_share_error: 'There was an error sharing the chat link', com_ui_share_error: 'There was an error sharing the chat link',
com_ui_share_retrieve_error: 'There was an error retrieving the shared links', com_ui_share_retrieve_error: 'There was an error retrieving the shared links',
com_ui_share_delete_error: 'There was an error deleting the shared link', com_ui_share_delete_error: 'There was an error deleting the shared link',
com_ui_bulk_delete_error: 'Failed to delete shared links',
com_ui_bulk_delete_partial_error: 'Failed to delete {0} shared links',
com_ui_share_create_message: 'Your name and any messages you add after sharing stay private.', com_ui_share_create_message: 'Your name and any messages you add after sharing stay private.',
com_ui_share_created_message: com_ui_share_created_message:
'A shared link to your chat has been created. Manage previously shared chats at any time via Settings.', 'A shared link to your chat has been created. Manage previously shared chats at any time via Settings.',
@ -442,6 +443,14 @@ export default {
com_ui_add_multi_conversation: 'Add multi-conversation', com_ui_add_multi_conversation: 'Add multi-conversation',
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?', com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
com_ui_page: 'Page', com_ui_page: 'Page',
com_ui_refresh_link: 'Refresh link',
com_ui_show_qr: 'Show QR Code',
com_ui_hide_qr: 'Hide QR Code',
com_ui_title: 'Title',
com_ui_view_source: 'View source chat',
com_ui_shared_link_delete_success: 'Successfully deleted shared link',
com_ui_shared_link_bulk_delete_success: 'Successfully deleted shared links',
com_ui_search: 'Search',
com_auth_error_login: com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.', 'Unable to login with the information provided. Please check your credentials and try again.',
com_auth_error_login_rl: com_auth_error_login_rl:

View file

@ -142,7 +142,6 @@ export default {
com_ui_create: 'Crear', com_ui_create: 'Crear',
com_ui_share: 'Compartir', com_ui_share: 'Compartir',
com_ui_copy_link: 'Copiar enlace', com_ui_copy_link: 'Copiar enlace',
com_ui_update_link: 'Actualizar enlace',
com_ui_create_link: 'Crear enlace', com_ui_create_link: 'Crear enlace',
com_ui_share_link_to_chat: 'Compartir enlace en el chat', com_ui_share_link_to_chat: 'Compartir enlace en el chat',
com_ui_share_error: 'Hubo un error al compartir el enlace del chat', com_ui_share_error: 'Hubo un error al compartir el enlace del chat',

View file

@ -243,7 +243,6 @@ export default {
com_ui_share: 'Jaa', com_ui_share: 'Jaa',
com_ui_share_var: 'Jaa {0}', com_ui_share_var: 'Jaa {0}',
com_ui_copy_link: 'Kopioi linkki', com_ui_copy_link: 'Kopioi linkki',
com_ui_update_link: 'Päivitä linkki',
com_ui_create_link: 'Luo linkki', com_ui_create_link: 'Luo linkki',
com_ui_share_to_all_users: 'Jaa kaikille käyttäjille', com_ui_share_to_all_users: 'Jaa kaikille käyttäjille',
com_ui_my_prompts: 'Omat syötteet', com_ui_my_prompts: 'Omat syötteet',

View file

@ -401,7 +401,6 @@ export default {
com_ui_copied: 'Copié !', com_ui_copied: 'Copié !',
com_ui_copy_code: 'Copier le code', com_ui_copy_code: 'Copier le code',
com_ui_copy_link: 'Copier le lien', com_ui_copy_link: 'Copier le lien',
com_ui_update_link: 'Mettre à jour le lien',
com_ui_create_link: 'Créer un lien', com_ui_create_link: 'Créer un lien',
com_nav_source_chat: 'Afficher la conversation source', com_nav_source_chat: 'Afficher la conversation source',
com_ui_date_today: 'Aujourd\'hui', com_ui_date_today: 'Aujourd\'hui',

View file

@ -96,7 +96,6 @@ export default {
com_ui_create: 'צור', com_ui_create: 'צור',
com_ui_share: 'שתף', com_ui_share: 'שתף',
com_ui_copy_link: 'העתק קישור', com_ui_copy_link: 'העתק קישור',
com_ui_update_link: 'עדכן קישור',
com_ui_create_link: 'צור קישור', com_ui_create_link: 'צור קישור',
com_ui_share_link_to_chat: 'שתף קישור בצ\'אט', com_ui_share_link_to_chat: 'שתף קישור בצ\'אט',
com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט', com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט',

View file

@ -61,7 +61,6 @@ export default {
com_ui_chats: 'chat', com_ui_chats: 'chat',
com_ui_share: 'Bagikan', com_ui_share: 'Bagikan',
com_ui_copy_link: 'Salin tautan', com_ui_copy_link: 'Salin tautan',
com_ui_update_link: 'Perbarui tautan',
com_ui_create_link: 'Buat tautan', com_ui_create_link: 'Buat tautan',
com_ui_share_link_to_chat: 'Bagikan tautan ke chat', com_ui_share_link_to_chat: 'Bagikan tautan ke chat',
com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat', com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat',

View file

@ -192,7 +192,6 @@ export default {
com_ui_create: 'Crea', com_ui_create: 'Crea',
com_ui_share: 'Condividi', com_ui_share: 'Condividi',
com_ui_copy_link: 'Copia link', com_ui_copy_link: 'Copia link',
com_ui_update_link: 'Aggiorna link',
com_ui_create_link: 'Crea link', com_ui_create_link: 'Crea link',
com_ui_share_link_to_chat: 'Condividi link a chat', com_ui_share_link_to_chat: 'Condividi link a chat',
com_ui_share_error: 'Si è verificato un errore durante la condivisione del link della chat', com_ui_share_error: 'Si è verificato un errore durante la condivisione del link della chat',

View file

@ -291,7 +291,6 @@ export default {
com_ui_share_var: '{0} を共有', com_ui_share_var: '{0} を共有',
com_ui_enter_var: '{0} を入力', com_ui_enter_var: '{0} を入力',
com_ui_copy_link: 'リンクをコピー', com_ui_copy_link: 'リンクをコピー',
com_ui_update_link: 'リンクを更新する',
com_ui_create_link: 'リンクを作成する', com_ui_create_link: 'リンクを作成する',
com_ui_share_to_all_users: '全ユーザーと共有', com_ui_share_to_all_users: '全ユーザーと共有',
com_ui_my_prompts: 'マイ プロンプト', com_ui_my_prompts: 'マイ プロンプト',

View file

@ -51,7 +51,6 @@ export default {
com_ui_chats: '채팅', com_ui_chats: '채팅',
com_ui_share: '공유하기', com_ui_share: '공유하기',
com_ui_copy_link: '링크 복사', com_ui_copy_link: '링크 복사',
com_ui_update_link: '링크 업데이트',
com_ui_create_link: '링크 만들기', com_ui_create_link: '링크 만들기',
com_ui_share_link_to_chat: '채팅으로 링크 공유하기', com_ui_share_link_to_chat: '채팅으로 링크 공유하기',
com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다', com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다',

View file

@ -55,7 +55,6 @@ export default {
com_ui_chats: 'chats', com_ui_chats: 'chats',
com_ui_share: 'Delen', com_ui_share: 'Delen',
com_ui_copy_link: 'Link kopiëren', com_ui_copy_link: 'Link kopiëren',
com_ui_update_link: 'Link bijwerken',
com_ui_create_link: 'Link aanmaken', com_ui_create_link: 'Link aanmaken',
com_ui_share_link_to_chat: 'Deel link naar chat', com_ui_share_link_to_chat: 'Deel link naar chat',
com_ui_share_error: 'Er is een fout opgetreden bij het delen van de chatlink', com_ui_share_error: 'Er is een fout opgetreden bij het delen van de chatlink',

View file

@ -32,7 +32,6 @@ export default {
'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc', 'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc',
com_ui_share: 'Udostępnij', com_ui_share: 'Udostępnij',
com_ui_copy_link: 'Skopiuj link', com_ui_copy_link: 'Skopiuj link',
com_ui_update_link: 'Zaktualizuj link',
com_ui_create_link: 'Utwórz link', com_ui_create_link: 'Utwórz link',
com_ui_share_link_to_chat: 'Udostępnij link w czacie', com_ui_share_link_to_chat: 'Udostępnij link w czacie',
com_ui_share_error: 'Wystąpił błąd podczas udostępniania linku do czatu', com_ui_share_error: 'Wystąpił błąd podczas udostępniania linku do czatu',

View file

@ -70,7 +70,6 @@ export default {
com_ui_connect: 'Подключить', com_ui_connect: 'Подключить',
com_ui_share: 'Поделиться', com_ui_share: 'Поделиться',
com_ui_copy_link: 'Скопировать ссылку', com_ui_copy_link: 'Скопировать ссылку',
com_ui_update_link: 'Обновить ссылку',
com_ui_create_link: 'Создать ссылку', com_ui_create_link: 'Создать ссылку',
com_ui_share_link_to_chat: 'Поделиться ссылкой в чате', com_ui_share_link_to_chat: 'Поделиться ссылкой в чате',
com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат', com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат',

View file

@ -52,7 +52,6 @@ export default {
com_ui_chats: 'chattar', com_ui_chats: 'chattar',
com_ui_share: 'Dela', com_ui_share: 'Dela',
com_ui_copy_link: 'Kopiera länk', com_ui_copy_link: 'Kopiera länk',
com_ui_update_link: 'Uppdatera länk',
com_ui_create_link: 'Skapa länk', com_ui_create_link: 'Skapa länk',
com_ui_share_link_to_chat: 'Dela länk till chatt', com_ui_share_link_to_chat: 'Dela länk till chatt',
com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken', com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken',

View file

@ -221,7 +221,6 @@ export default {
com_ui_create: 'Oluştur', com_ui_create: 'Oluştur',
com_ui_share: 'Paylaş', com_ui_share: 'Paylaş',
com_ui_copy_link: 'Bağlantıyı kopyala', com_ui_copy_link: 'Bağlantıyı kopyala',
com_ui_update_link: 'Bağlantıyı güncelle',
com_ui_create_link: 'Bağlantı oluştur', com_ui_create_link: 'Bağlantı oluştur',
com_ui_share_link_to_chat: 'Sohbete bağlantı paylaş', com_ui_share_link_to_chat: 'Sohbete bağlantı paylaş',
com_ui_share_error: 'Sohbet bağlantısını paylaşırken bir hata oluştu', com_ui_share_error: 'Sohbet bağlantısını paylaşırken bir hata oluştu',

View file

@ -54,7 +54,6 @@ export default {
com_ui_chats: 'cuộc trò chuyện', com_ui_chats: 'cuộc trò chuyện',
com_ui_share: 'Chia sẻ', com_ui_share: 'Chia sẻ',
com_ui_copy_link: 'Sao chép liên kết', com_ui_copy_link: 'Sao chép liên kết',
com_ui_update_link: 'Cập nhật liên kết',
com_ui_create_link: 'Tạo liên kết', com_ui_create_link: 'Tạo liên kết',
com_ui_share_link_to_chat: 'Chia sẻ liên kết đến cuộc trò chuyện', com_ui_share_link_to_chat: 'Chia sẻ liên kết đến cuộc trò chuyện',
com_ui_share_error: 'Có lỗi xảy ra khi chia sẻ liên kết trò chuyện', com_ui_share_error: 'Có lỗi xảy ra khi chia sẻ liên kết trò chuyện',

View file

@ -278,7 +278,6 @@ export default {
com_ui_share_var: '共享 {0}', com_ui_share_var: '共享 {0}',
com_ui_enter_var: '输入 {0}', com_ui_enter_var: '输入 {0}',
com_ui_copy_link: '复制链接', com_ui_copy_link: '复制链接',
com_ui_update_link: '更新链接',
com_ui_create_link: '创建链接', com_ui_create_link: '创建链接',
com_ui_share_to_all_users: '共享给所有用户', com_ui_share_to_all_users: '共享给所有用户',
com_ui_my_prompts: '我的提示词', com_ui_my_prompts: '我的提示词',

View file

@ -51,7 +51,6 @@ export default {
com_ui_chats: '對話', com_ui_chats: '對話',
com_ui_share: '分享', com_ui_share: '分享',
com_ui_copy_link: '複製連結', com_ui_copy_link: '複製連結',
com_ui_update_link: '更新連結',
com_ui_create_link: '建立連結', com_ui_create_link: '建立連結',
com_ui_share_link_to_chat: '分享連結到聊天', com_ui_share_link_to_chat: '分享連結到聊天',
com_ui_share_error: '分享聊天連結時發生錯誤', com_ui_share_error: '分享聊天連結時發生錯誤',

View file

@ -60,7 +60,8 @@ html {
--surface-tertiary: var(--gray-100); --surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white); --surface-tertiary-alt: var(--white);
--surface-dialog: var(--white); --surface-dialog: var(--white);
--surface-submit: var(--green-500); --surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--border-light: var(--gray-200); --border-light: var(--gray-200);
--border-medium-alt: var(--gray-300); --border-medium-alt: var(--gray-300);
--border-medium: var(--gray-300); --border-medium: var(--gray-300);
@ -114,7 +115,8 @@ html {
--surface-tertiary: var(--gray-700); --surface-tertiary: var(--gray-700);
--surface-tertiary-alt: var(--gray-700); --surface-tertiary-alt: var(--gray-700);
--surface-dialog: var(--gray-850); --surface-dialog: var(--gray-850);
--surface-submit: var(--green-600); --surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--border-light: var(--gray-700); --border-light: var(--gray-700);
--border-medium-alt: var(--gray-600); --border-medium-alt: var(--gray-600);
--border-medium: var(--gray-600); --border-medium: var(--gray-600);
@ -2412,3 +2414,42 @@ button.scroll-convo {
height: auto !important; height: auto !important;
max-height: none !important; max-height: none !important;
} }
/** AnimatedSearchInput style */
@keyframes gradient-x {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-gradient-x {
background-size: 200% 200%;
animation: gradient-x 15s ease infinite;
}
.animate-pulse-subtle {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
.scale-98 {
transform: scale(0.98);
}

View file

@ -11,7 +11,6 @@ export * from './textarea';
export * from './messages'; export * from './messages';
export * from './languages'; export * from './languages';
export * from './endpoints'; export * from './endpoints';
export * from './sharedLink';
export * from './localStorage'; export * from './localStorage';
export * from './promptGroups'; export * from './promptGroups';
export { default as cn } from './cn'; export { default as cn } from './cn';

View file

@ -1,955 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import type { SharedLinkListData } from 'librechat-data-provider';
const today = new Date();
today.setDate(today.getDate() - 3);
export const sharedLinkData: SharedLinkListData = {
pages: [
{
sharedLinks: [
{
conversationId: '7a327f49-0850-4741-b5da-35373e751256',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T04:31:04.897Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b3c2e29b131c464182b483c4',
'6dc217152a134ac1826fc46c',
'483658114d104691b2501fbf',
'cfb8467cfd30438e8268cf92',
],
shareId: '62f850ad-a0d8-48a5-b439-2d1dbaba291c',
title: 'Test Shared Link 1',
updatedAt: '2024-04-11T11:10:42.329Z',
},
{
conversationId: '1777ad5f-5e53-4847-be49-86f66c649ac6',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-05T05:59:31.571Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'bc53fda136ba46fb965260b8',
'138b83d659c84250904feb53',
'1c750ffab31546bd85b81360',
'7db87f183e4d489fae0b5161',
'64ee2004479644b7b5ffd2ea',
'4dd2b9a0704c4ae79688292e',
'25394c2bb2ee40feaf67836f',
'838ed537d9054780a3d9f272',
'300728390f8c4021a6c066ca',
'ea30b637cb8f463192523919',
],
shareId: '1f43f69f-0562-4129-b181-3c37df0df43e',
title: 'Test Shared Link 2',
updatedAt: '2024-04-16T17:52:40.250Z',
},
{
conversationId: 'a9682067-a7c9-4375-8efb-6b8fa1c71def',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-03T08:23:35.147Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'bb4fe223548b480eae6d64af',
'420ef02293d0470b96980e7b',
'ae0ffbb27e13418fbd63e7c2',
'43df3ea55cfb4219b1630518',
'c4fb3be788404058a4c9780d',
'6ee6a5833b1d4849a95be890',
'0b8a3ecf5ca5449b9bdc0ed8',
'a3daed97f0e5432a8b6031c0',
'6a7d10c55c9a46cfbd08d6d2',
'216d40fa813a44059bd01ab6',
],
shareId: 'e84d2642-9b3a-4e20-b92a-11a37eebe33f',
title: 'Test Shared Link 3',
updatedAt: '2024-02-06T04:21:17.065Z',
},
{
conversationId: 'b61f9a0a-6d5d-4d0e-802b-4c1866428816',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-06T19:25:45.708Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['00aad718514044dda8e044ec', '8cb3b67fccd64b8c8ac0abbb'],
shareId: '9011e12a-b2fe-4003-9623-bf1b5f80396b',
title: 'Test Shared Link 4',
updatedAt: '2024-03-21T22:37:32.704Z',
},
{
conversationId: '4ac3fd9e-437b-4988-b870-29cacf28abef',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-03T15:45:11.220Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'6b05f825ca7747f294f2ac64',
'871ee06fb6a141879ca1cb25',
'47b05821c6134a3b9f21072e',
],
shareId: '51d3ab25-195e-47d0-a5e3-d0694ece776a',
title: 'Test Shared Link 5',
updatedAt: '2024-04-03T23:20:11.213Z',
},
{
conversationId: '6ed26de8-3310-4abb-b561-4bdae9400aac',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-22T19:12:14.995Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ac2929efa82b4cd78aae02d6',
'4266450abc7b41a59887e99d',
'95df3c7c802c40e0b643bb96',
'f21038af46074e51a2c4bd87',
'3f064bc8589c435786a92bcb',
],
shareId: 'c3bc13ed-190a-4ffa-8a05-50f8dad3c83e',
title: 'Test Shared Link 6',
updatedAt: '2024-04-25T19:55:25.785Z',
},
{
conversationId: 'b3c0aaca-ee76-42a2-b53b-5e85baca2f91',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T00:37:12.929Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5a44ebd0bf05418e98cc9e5d',
'88b93127aef74bfb94666ac1',
'bf654993c34743c9a5a1b76c',
'2514259bd702491e924da475',
'60dbbf91a6734aa081e082cd',
'11efabaa3a8f4df8bf85410b',
'3f5bbf38abdb42efa65a8740',
'5b9dd8246dde41ae9ebd57c4',
],
shareId: '871d41fe-fb8a-41d4-8460-8bb93fb8aa98',
title: 'Test Shared Link 7',
updatedAt: '2024-03-13T14:34:26.790Z',
},
{
conversationId: '2071122a-57cc-4f16-baa8-1e8af3e23522',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-24T03:22:58.012Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8c94aad21caa45d6acb863c8',
'c10e4e0bfe554a94920093ba',
'2e4c2e2238f24f63b08440bc',
'05bacd00320342298f9f439f',
'c8b7750a7d8a4e2fbdc2630b',
'a84573fea668476a87207979',
'6ab15a1b96c24798b1bddd6f',
'b699d8e42324493eae95ca44',
],
shareId: 'f90f738a-b0ac-4dba-bb39-ad3d77919a21',
title: 'Test Shared Link 8',
updatedAt: '2024-01-22T11:09:51.834Z',
},
{
conversationId: 'ee06374d-4452-4fbe-a1c0-5dbc327638f9',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-03T19:24:21.281Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'297d0827c81a4da0a881561a',
'3131ef1b3c484542b0db1f92',
'e8879a50340c49449e970dbc',
'fe598327a93b4b0399055edd',
'acc7a2a24e204325befffbcd',
'6ec3c6450e124cbf808c8839',
'714e3443f62045aaaff17f93',
'014be593aaad41cab54a1c44',
],
shareId: '0fc91bab-083d-449f-add3-1e32146b6c4a',
title: 'Test Shared Link 9',
updatedAt: '2024-03-14T00:52:52.345Z',
},
{
conversationId: '0d2f6880-cacf-4f7b-930e-35881df1cdea',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-14T03:18:45.587Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['1d045c1cf37742a6a979e21b'],
shareId: 'd87deb62-b993-476c-b520-104b08fd7445',
title: 'Test Shared Link 10',
updatedAt: '2024-03-26T18:38:41.222Z',
},
{
conversationId: '1fe437fd-68f0-4e3e-81a9-ca9a0fa8220a',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-16T19:55:23.412Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a28b5f27c95e4700bcd158dc',
'6e85f0a8b6ae4107a5819317',
'fa5b863c91224a0098aebd64',
'b73811a510e54acebe348371',
'f3f7f7d7b69a485da727f9c2',
'81d82df3098c4e359d29703f',
],
shareId: '704a1a9c-5366-4f55-b69e-670a374f4326',
title: 'Test Shared Link 11',
updatedAt: '2024-04-11T05:00:25.349Z',
},
{
conversationId: '50465c8e-102f-4f94-88c2-9cf607a6c336',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-05T21:57:52.289Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a64886199ab641c29eb6fdaf',
'9c16497010354cf385d4cc1d',
'36cdeb4d1e4f45078edfe28a',
'a11f4ea78fa44f57bfc5bfc6',
'dea42fcfe7a544feb5debc26',
'ece0d630cd89420ca80ffe25',
'719165a5d80644ae8fae9498',
'f27111921a10470982f522b2',
'10b78255f7a24b6192e67693',
],
shareId: 'e47eaf30-c1ed-4cc2-b2b8-8cdec4b1ea2f',
title: 'Test Shared Link 12',
updatedAt: '2024-02-07T15:43:21.110Z',
},
{
conversationId: '1834f551-0a68-4bc7-a66a-21a234462d24',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-23T02:58:52.653Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'cb5d19d986194f779a6f47fd',
'72159d6668f347f99398aec9',
'cbe535213d664a6280d9a19e',
'8dccceadcb3a44148962ba47',
],
shareId: '976b55cb-d305-40f8-ae06-ae516f4e49f5',
title: 'Test Shared Link 13',
updatedAt: '2024-05-02T10:21:05.190Z',
},
{
conversationId: 'd8175b2f-f7c0-4f61-850d-f524bf8a84df',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-09T09:04:10.576Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'0f3708fc670d46998b1294d5',
'794520b9cee84c23bff01d5a',
'b05d2af2d37c426a970d8326',
'bd4239e379284d01acb9aaf4',
'e6265cfbbd88420781b27248',
'5262193aef7c426cafe2ee85',
'848569e2ca4843beaf64efc4',
'99f3b438241c4454a6784ac2',
'111d346fbeae4806bdf23490',
'fe4bde34e1a143f1a12fa628',
],
shareId: '928eb0a8-e0ea-470d-8b6a-92e0981d61b0',
title: 'Test Shared Link 14',
updatedAt: '2024-04-15T18:00:13.094Z',
},
{
conversationId: '281984c0-fed0-4428-8e50-c7c93cba4ae0',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-23T23:26:41.956Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'7e781fd08408426795c243e7',
'3c6d729fd3524a65a7b2a5e3',
'53bdbec6ee6148e78795d6e1',
'46f8170f28684ccc8ee56f33',
'3350d9aa7c814c89af6d3640',
],
shareId: '7a251af6-1ad3-4b24-830c-21b38124f325',
title: 'Test Shared Link 15',
updatedAt: '2024-03-18T16:33:35.498Z',
},
{
conversationId: '09610b11-6087-4d15-b163-e1bc958f2e82',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-05T20:00:36.159Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['6dce61720af24c70926efe87'],
shareId: '2b389d5e-eb24-4b29-a8e1-c0545cdfa1fc',
title: 'Test Shared Link 16',
updatedAt: '2024-02-23T05:49:50.020Z',
},
{
conversationId: '0c388322-905c-4c57-948c-1ba9614fdc2f',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-05T00:03:20.078Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'e3755ff2cf9f403c9d20901f',
'e32733b8da1440ec9d9dc2df',
'e2870d0361634d4f867e1e57',
'2e504afb8675434bb9f58cb5',
'ea38d76735c54f94bf378ed3',
'8712cda1bfc8480eba6c65aa',
'3f43a655706f4032a9e1efb4',
'3f890f8279f4436da2a7d767',
'4ca7616c04404391a7cfc94f',
'd3e176a831ff48e49debabce',
],
shareId: '2866400b-bcb9-43a4-8cbf-6597959f8c55',
title: 'Test Shared Link 17',
updatedAt: '2024-03-16T02:53:06.642Z',
},
{
conversationId: '5ac2b90a-63f8-4388-919b-40a1c1fea874',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-21T15:30:37.893Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'734a0427c6224fca87e2a89d',
'6af13387ddf0495d9c6ebad9',
'02a93d5659f343678b12b932',
'8af2f028c5114286a3339075',
'3a8bec13fc574fb9a9f938e2',
'6f4aa482286548b7b42668e6',
'c1d4f94a2eaf4e44b94c5834',
'442d9491b51d49fcab60366d',
'82a115a84b2a4457942ca6cf',
'152d8c2894a0454d9248c9f5',
],
shareId: 'e76f6a90-06f3-4846-8e3d-987d37af27b5',
title: 'Test Shared Link 18',
updatedAt: '2024-01-27T06:25:27.032Z',
},
{
conversationId: '01521fef-aa0b-4670-857d-f19bfc0ce664',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-01T21:46:40.674Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'222cf562d8e24b1b954395c2',
'c6f299f588c24905b771e623',
'f023f30fd4d9472c9bf60b84',
'e4929e3f14d748a18656f1be',
'a01f453fcb0a49b5b488a22c',
'4ceee6b365ab4386bacb4d27',
'c2cab81da0be4c6e97f11f92',
'644c32d10f2f4e2086d5e04d',
'5225d1286db14cc6a47fdea5',
'c821ebb220ae495b98f2e17f',
],
shareId: '1b2d8bf5-ff90-478a-bdf6-ea622fb4875a',
title: 'Test Shared Link 19',
updatedAt: '2024-02-25T15:52:56.189Z',
},
{
conversationId: '54f5d332-efc7-4062-9e1d-c70c3dbbc964',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-29T15:57:22.808Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'49771038e2dd4de0a28b19f2',
'0debd4ad13de4db9a65fe589',
'a9c8e6e34c34486ca27b7c88',
'd7b0ace0438146789e8b1899',
],
shareId: '4f5eea7d-b3a8-4b72-ad1e-a4d516c582c2',
title: 'Test Shared Link 20',
updatedAt: '2024-03-18T13:12:10.828Z',
},
{
conversationId: '99dabf25-46a5-43bb-8274-715c64e56032',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-05T03:35:11.327Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['965a55515727404eb70dec8f'],
shareId: '2360b7c1-20d7-46b9-919d-65576a899ab9',
title: 'Test Shared Link 21',
updatedAt: '2024-04-17T11:22:12.800Z',
},
{
conversationId: '1e2ffc1a-3546-460e-819c-689eb88940c6',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-22T08:40:32.663Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'131f4b03ad3d4e90803a203d',
'7f55262c554f4d97a8fef0ec',
'341e8fea28e241fc8b5a2398',
],
shareId: 'f3e370ed-420c-4579-a033-e18743b49485',
title: 'Test Shared Link 22',
updatedAt: '2024-04-07T22:06:07.162Z',
},
{
conversationId: '14510a0c-01cc-4bfb-8336-3388573ac4d8',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-08T08:20:28.619Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'022f87b1bf0d4e4688970daa',
'42519e8f3603496faae0969c',
'abc29ac88d66485aa11e4b58',
],
shareId: '0f46f1fd-95d3-4a6f-a5aa-ae5338dc5337',
title: 'Test Shared Link 23',
updatedAt: '2024-03-06T12:05:33.679Z',
},
{
conversationId: '2475594e-10dc-4d6a-aa58-5ce727a36271',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T07:43:46.952Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5d0cd8bef4c241aba5d822a8',
'a19669a364d84ab5bbafbe0c',
'336686022ea6456b9a63879d',
'3323c9b85acc4ffba35aad04',
'bf15e8860a01474cb4744842',
'5a055eb825ed4173910fffd5',
'36a5e683ad144ec68c2a8ce0',
'8bc1d5590a594fa1afc18ee1',
'f86444b60bea437ba0d0ef8e',
'5be768788d984723aef5c9a0',
],
shareId: 'b742f35c-e6a3-4fa4-b35d-abab4528d7d6',
title: 'Test Shared Link 24',
updatedAt: '2024-03-27T15:31:10.930Z',
},
{
conversationId: 'ddb5a61c-82fe-4cc7-a2b0-c34b8c393b28',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-15T02:06:45.901Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'059d7ae5405a42af9c52171d',
'303efd2e676e4fe7aa9fa9d0',
'9f459c2e6a23411ea4a3e153',
'6036a3785adc4b7caa7ea22b',
'65251979d0c64d1f8821b3d9',
'25fdeb5ed99d42cca3041e08',
'61baa25e4e3d42a3aefd6c16',
'91dc4578fee749aeb352b5ea',
'd52daca5afb84e7890d5d9ad',
],
shareId: '13106e5f-1b5f-4ed4-963d-790e61c1f4c8',
title: 'Test Shared Link 25',
updatedAt: '2024-02-05T08:39:45.847Z',
},
{
conversationId: 'df09c89b-0b0d-429c-9c93-b5f4d51ef1ec',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-28T07:50:10.724Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'7f007af305ee4f2197c125d3',
'318b26abfe864dbf9f557bf9',
'0c4709b489ac4211b9f22874',
'8940f9ab45f44b56911819d5',
'b47ec3aa0cf7413fa446f19b',
'3857f85f492f4e11aa0ea377',
],
shareId: '31bbafa4-2051-4a20-883b-2f8557c46116',
title: 'Test Shared Link 26',
updatedAt: '2024-02-01T19:52:32.986Z',
},
{
conversationId: '856a4d54-54f7-483f-9b4e-7b798920be25',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-14T08:57:03.592Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b5afc1f3569d44378bc7539d',
'e54804329577443d8685d3b1',
'7b10204ad48c464aac2b752a',
'8e96d562d33b4d6b85f2269e',
'cd844644f15d4dbdb5772a3b',
'91f5159278ca420c8a0097b2',
'5f8cf34736df4cca962635c1',
'96e2169ddcf5408fb793aeb6',
'988d96959afb4ec08cd3cec4',
'173398cdf05d4838aeb5ad9f',
],
shareId: '88c159a0-0273-4798-9d21-f95bd650bd30',
title: 'Test Shared Link 27',
updatedAt: '2024-05-08T20:07:46.345Z',
},
{
conversationId: '41ee3f3f-36a5-4139-993a-1c4d7d055ccb',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-26T10:08:29.943Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['883cc3286240405ba558f322', '7ca7809f471e481fa9944795'],
shareId: '97dc26aa-c909-4a9c-91be-b605d25b9cf3',
title: 'Test Shared Link 28',
updatedAt: '2024-04-06T17:36:05.767Z',
},
{
conversationId: '79e30f91-9b87-484c-8a12-6e4c6e8973d4',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-05-07T05:28:58.595Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a8ac347785504b51bdad7ea7',
'ce85321aecf64355b0362f8c',
'21a462895f37474d8d6acdfd',
'095d9104011e4534bda93294',
'503b6e27677c457289366a8d',
'1738d52a60004c9ba6f0c9ec',
'a157fe44a67f4882a507941b',
'40e30dc275394eb4b9921db0',
'f4ed9f2fb08640fcbacaa6a7',
'bbac358328864dc2bfaa39da',
],
shareId: 'aa36fc45-2a73-4fa2-a500-2a9148fca67d',
title: 'Test Shared Link 29',
updatedAt: '2024-01-26T16:45:59.269Z',
},
{
conversationId: 'f5eaa000-3657-43d4-bc55-538108723b83',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-22T15:51:31.330Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a87cfce565844b4ba9230dc5',
'426723bc4c22425e9bdf4b7b',
'73be5795469a444b8f1eca88',
'75a87212574a4cfc80d7d4e3',
'80f982dfc3e94535aed6e7d4',
'86d036c912c142ca8ec0f45a',
'e3435fbbd4d2443eba30e97d',
'e451e124aa964398b596af5d',
'1a13913f55e9442e8b5d7816',
],
shareId: 'fe0f7ea2-74d2-40ba-acb2-437e61fc3bef',
title: 'Test Shared Link 30',
updatedAt: '2024-02-27T13:29:04.060Z',
},
{
conversationId: 'a1ad92b4-6fac-44be-bad6-7648aeeba7af',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-10T09:32:22.242Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5931cd94fbcd4fbcbaa20b91',
'0bc5f38ccc4f4b88afa42aed',
'7b4375d65f3f4524a79cb5f0',
'd2ce098360ce4d19b6961017',
'847f5ee8d2df49a0ba1fd8a7',
'6164a71770c745ea8142a37c',
'e98a0f1e15c846ac9b113608',
'5297d7df09b44d088cf80da5',
'62260b3f62ba423aa5c1962c',
'21fffc89d1d54e0190819384',
],
shareId: 'ee5ae35d-540d-4a01-a938-ee7ee97b15ce',
title: 'Test Shared Link 31',
updatedAt: '2024-02-26T03:37:24.862Z',
},
{
conversationId: '1e502d46-c710-4848-9bf2-674c08e51d9c',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-09T08:37:01.082Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'adb4bfb7657d4d7d92e82edf',
'70bdd81466e0408399b415d3',
'ef99511981dc4c3baa18d372',
],
shareId: 'b4fd8b63-7265-4825-89a4-9cebcbaadeee',
title: 'Test Shared Link 32',
updatedAt: '2024-02-27T04:32:40.654Z',
},
{
conversationId: 'd1a43c39-f05e-4c6e-a8c2-0fcca9cb8928',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-26T15:03:25.546Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'07c070ab8a7541fea96b131c',
'eb89cc57bcbb47ecb497cd5f',
'651999e46e734837b24c2500',
'608f9fbbbbb645e6b32d7d46',
],
shareId: '5a4cf7d0-0abb-48c1-8e70-9f4ee3220dc4',
title: 'Test Shared Link 33',
updatedAt: '2024-04-06T21:39:51.521Z',
},
{
conversationId: 'e549be2b-2623-42a3-8315-a8e35a7776b3',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-23T21:40:32.151Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'aa4e0b65650544589afb5961',
'160841178c0944e88de83956',
'234ac16af26d48a7875ee643',
],
shareId: 'b083f048-2803-407e-b54a-89261db87ade',
title: 'Test Shared Link 34',
updatedAt: '2024-03-14T12:16:32.984Z',
},
{
conversationId: '39f415ea-48f2-4bb2-b6f8-c2cf2d5fe42a',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-08T19:02:27.141Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'c77e79bb92b64d36a72d5a4d',
'ea236310a9ba4b27a2217f09',
'b25c46f2d23542f6b9d94de9',
],
shareId: 'a9871169-7012-4206-b35c-7d207309a0f5',
title: 'Test Shared Link 35',
updatedAt: '2024-04-21T04:00:58.151Z',
},
{
conversationId: 'c0d00265-12c4-45d0-a8bd-95d6e1bda769',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-14T09:50:55.476Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'63cdf78acd0449cf90237b29',
'b93d82d7612b49fc98f0c211',
'e56afe7e6e1e478d858a96d0',
'09344c8d22e74ce9b1d615cc',
],
shareId: 'aa1262ab-54c9-406a-a97f-e2636266cf3e',
title: 'Test Shared Link 36',
updatedAt: '2024-03-24T15:53:36.021Z',
},
{
conversationId: '5114b13d-8050-4e29-a2fd-85c776aec055',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-20T20:39:54.322Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'd397dc5c136a4c7da44e2fb9',
'a3cd29243629450b87852a85',
'9dd1e0e918844a37ba8dc955',
'ec2a73f7efe344fe85709c22',
'4d4702651869476b8ae397fd',
'8447430fd4f34aab82921018',
'8d804ee086734d6192b59995',
'29d6ccba37234bb8bd280977',
'31ec4f8c28cc4c21828ecef8',
'8ea630045b5847ec92651f4a',
],
shareId: '2021fcab-7000-4840-9a8c-f0a1cb1ce8fa',
title: 'Test Shared Link 37',
updatedAt: '2024-04-08T02:09:33.732Z',
},
{
conversationId: 'afa796fe-c8c1-411d-98d1-a8c8c8550412',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-16T23:58:11.179Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8f54ee5871494f1b9f13f314',
'7778849398db40eb950952fb',
'65977e5d9e12445cb1cd9a54',
'8dba76884b09490a91b1aff9',
'2f6cc465171742b8a529daa3',
'1775b24fe2e94cd89dd6164e',
'780d980e59274240837c0bff',
],
shareId: '9bb78460-0a26-4df7-be54-99b904b8084a',
title: 'Test Shared Link 38',
updatedAt: '2024-04-22T00:33:47.525Z',
},
{
conversationId: 'c70fc447-acfc-4b57-84aa-2d8abcc3c5a5',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-24T11:39:14.696Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b659ff86f9284ae1a40bee94',
'35bce7b6b2124db491f116c4',
'cf0bad6c2623413babb33e65',
'26c6ce4d46614c86941d5429',
'fba6517fc3434c188d8e1471',
'3e37398cc2ea4e50920d6271',
'fd8584b1cf8145c88697b89d',
'8e433df0ada34e2280d4bd91',
'fc52f80a6df24df5baccb657',
'95cdf9b05b8f4a81a70a37e9',
],
shareId: '0664b078-8f29-41ff-8c1c-85172c659195',
title: 'Test Shared Link 39',
updatedAt: '2024-03-29T10:22:50.815Z',
},
{
conversationId: '32ccaa36-cc46-4c84-888d-a86bf9b1d79c',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-04T03:13:19.399Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8a0cfa8f5e874cf089f91b2e',
'e9a72907ac9b4e88a8cfa737',
'aa328aaf978944e18727a967',
'8786577a76b24415920d87a0',
'ee05127d35ec415a85554406',
],
shareId: 'a0018d28-52a8-4d31-8884-037cf9037eb7',
title: 'Test Shared Link 40',
updatedAt: '2024-01-30T03:26:15.920Z',
},
{
conversationId: '2d8f1f40-b0e8-4629-937a-dee5036cb0bb',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-18T15:32:59.697Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'64475ed4f6234326a1104ca2',
'db0db3ee92e14afaba6db75b',
'1f28a30501a94e3d896c261b',
'de2eb08823db401d8262d3f3',
'254c32efae97476b954d8dc4',
'dda42e4e74144cb69e395392',
'85bfe89de9e643fb8d5fa8ff',
'2f52e060a8b645928d0bf594',
],
shareId: '9740b59b-cd84-461d-9fd7-2e1903b844b2',
title: 'Test Shared Link 41',
updatedAt: '2024-04-23T15:48:54.690Z',
},
{
conversationId: '5180353f-23a9-48af-8ed0-b05983ef87d1',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-15T10:45:51.373Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'012b97e2df45475b93ad1e37',
'23d5042117a142f5a12762d5',
'8eb8cbca953d4ec18108f6d8',
'ba506914203442339cd81d25',
'88c3b40cd0ae43d2b670ee41',
'0dd8fe241f5c4ea88730652c',
'80e3d1d7c26c489c9c8741fe',
'317a47a138c6499db73679f0',
'6497260d6a174f799cb56fd5',
],
shareId: 'a6eaf23e-6e99-4e96-8222-82149c48803b',
title: 'Test Shared Link 42',
updatedAt: '2024-02-24T12:08:27.344Z',
},
{
conversationId: 'cf3f2919-1840-4f6a-b350-f73f02ba6e90',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-14T06:20:45.439Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ba3b939f8a3443f99f37b296',
'b2039c988b3841c6b4ccb436',
'89ea6e1d4b3f440bb867d740',
'270210838a724aeb87e9bbe9',
'02dd6b2f185247d9888d5be1',
'6458fe13ee1c470ba33fb931',
],
shareId: '765042c0-144d-4f7b-9953-0553ed438717',
title: 'Test Shared Link 43',
updatedAt: '2024-04-11T05:23:05.750Z',
},
{
conversationId: '8efb71ee-7984-409a-b27c-aeb2650d78ba',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-28T16:41:04.100Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'cc60d9e2cbb7494481138833',
'1fb8d220b888475ba6c59fd3',
'5fd97817ab25451bb7ac22f5',
'9e8f7765a1bc4ab495da9081',
'4d5997d3c8744aaeb8c96964',
'd438acb0f7704201857d6916',
'b5106745d89f4a3fada8cd11',
'3b41562ce727411a83f44cdf',
'627f8f77feb843848145fc5f',
'6bee635eb10443ae9eef20ab',
],
shareId: 'ed0fe440-479d-4c79-a494-0f461612c474',
title: 'Test Shared Link 44',
updatedAt: '2024-04-15T12:41:00.324Z',
},
{
conversationId: '7cdd42a6-67bb-48c8-b8c3-bb55cbaa3905',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-24T23:13:42.892Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a944f461ca094d1c80bea677',
'bd4b516b51a84285846343b4',
'442f6b4c27f647199279e49c',
'e672974b3cf74cd3b85537f9',
],
shareId: '9439972e-226c-4386-910a-e629eb7019c3',
title: 'Test Shared Link 45',
updatedAt: '2024-01-17T07:42:21.103Z',
},
{
conversationId: '595bab25-e5c1-4bd0-99c1-a099391adb87',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-13T05:58:33.171Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'c39942615fdf435cb22369b5',
'0ec24a7328424a78b7dcecaf',
'335373a769fd43a5833eac16',
'22905090a44f4bf8b6f415f8',
],
shareId: '18501e23-3fc5-436d-a9aa-ccde7c5c9074',
title: 'Test Shared Link 46',
updatedAt: '2024-02-05T04:34:42.323Z',
},
{
conversationId: '822a650b-2971-441a-9cb0-b2ecabf7b3ba',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-20T10:29:20.771Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ed566d1ffd51494e9a069f32',
'3ca9a8bbfb7c43e49e4898d7',
'6534186966784f0fba45c1ab',
'8a9e394dda8542d4a4db1140',
'002d883a1c344de0beb794b3',
'61e9e872aa854288a4ac9694',
'11e465cb875746aaa5894327',
'ead6b00c855f4907ac5070af',
],
shareId: 'aaaf89e4-eb3d-45f8-9e24-f370d777d8f7',
title: 'Test Shared Link 47',
updatedAt: '2024-04-29T03:11:47.109Z',
},
{
conversationId: 'ce68ce26-07fc-4448-9239-f1925cfaaa72',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-15T15:04:08.691Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a1851d231ee748e59ed43494',
'363372c828d8443b81abffd4',
'0b2e97210bd14e229ddb6641',
],
shareId: 'f4de7c43-c058-43f5-bdab-0854d939dfb9',
title: 'Test Shared Link 48',
updatedAt: '2024-03-05T11:43:00.177Z',
},
{
conversationId: '3fafe417-b5f8-4cc8-ac8e-897ebef836bd',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-20T05:34:57.880Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'876337c495ca40c080b65c1d',
'b5e914ac15ff439a9836a9ea',
'cb6379d0a9ad442291d78c14',
'529424b650a4478ba012cf40',
'99ff1ed49cb2483bbd970730',
'0f0e215e179f4cfba56c7b03',
'210940fbe4c745d183358ed1',
'99246c796c7a44c2ae85a549',
'a2b967556867499eb437674a',
],
shareId: '79ec3716-ea2e-4045-8a82-056d63ebc939',
title: 'Test Shared Link 49',
updatedAt: '2024-03-19T08:01:13.445Z',
},
],
pages: 49,
pageNumber: '1',
pageSize: 25,
},
],
pageParams: [null],
};

View file

@ -1,149 +0,0 @@
import { sharedLinkData } from './sharedLink.fakeData';
import { addSharedLink, updateSharedLink, deleteSharedLink } from './sharedLink';
import type { TSharedLink, SharedLinkListData } from 'librechat-data-provider';
describe('Shared Link Utilities', () => {
describe('addSharedLink', () => {
it('adds a new shared link to the top of the list', () => {
const data = { pages: [{ sharedLinks: [] }] };
const newSharedLink = { shareId: 'new', updatedAt: '2023-04-02T12:00:00Z' };
const newData = addSharedLink(
data as unknown as SharedLinkListData,
newSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(1);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('new');
});
it('does not add a shared link but updates it if it already exists', () => {
const data = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newSharedLink = { shareId: '2', updatedAt: '2023-04-02T12:00:00Z' };
const newData = addSharedLink(
data as unknown as SharedLinkListData,
newSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('2');
});
});
describe('updateSharedLink', () => {
it('updates an existing shared link and moves it to the top', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const updatedSharedLink = { shareId: '1', updatedAt: '2023-04-02T12:00:00Z' };
const newData = updateSharedLink(
initialData as unknown as SharedLinkListData,
updatedSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('1');
});
it('does not update a shared link if it does not exist', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const updatedSharedLink = { shareId: '3', updatedAt: '2023-04-02T12:00:00Z' };
const newData = updateSharedLink(
initialData as unknown as SharedLinkListData,
updatedSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('1');
});
});
describe('deleteSharedLink', () => {
it('removes a shared link by id', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '1');
expect(newData.pages[0].sharedLinks).toHaveLength(1);
expect(newData.pages[0].sharedLinks[0].shareId).not.toBe('1');
});
it('does not remove a shared link if it does not exist', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '3');
expect(newData.pages[0].sharedLinks).toHaveLength(2);
});
});
});
describe('Shared Link Utilities with Fake Data', () => {
describe('addSharedLink', () => {
it('adds a new shared link to the existing fake data', () => {
const newSharedLink = {
shareId: 'new',
updatedAt: new Date().toISOString(),
} as TSharedLink;
const initialLength = sharedLinkData.pages[0].sharedLinks.length;
const newData = addSharedLink(sharedLinkData, newSharedLink);
expect(newData.pages[0].sharedLinks.length).toBe(initialLength + 1);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('new');
});
});
describe('updateSharedLink', () => {
it('updates an existing shared link within fake data', () => {
const updatedSharedLink = {
...sharedLinkData.pages[0].sharedLinks[0],
title: 'Updated Title',
};
const newData = updateSharedLink(sharedLinkData, updatedSharedLink);
expect(newData.pages[0].sharedLinks[0].title).toBe('Updated Title');
});
});
describe('deleteSharedLink', () => {
it('removes a shared link by id from fake data', () => {
const shareIdToDelete = sharedLinkData.pages[0].sharedLinks[0].shareId as string;
const newData = deleteSharedLink(sharedLinkData, shareIdToDelete);
const deletedDataExists = newData.pages[0].sharedLinks.some(
(c) => c.shareId === shareIdToDelete,
);
expect(deletedDataExists).toBe(false);
});
});
});

View file

@ -1,36 +0,0 @@
import { InfiniteCollections } from 'librechat-data-provider';
import { SharedLinkListData, SharedLinkListResponse, TSharedLink } from 'librechat-data-provider';
import { addData, deleteData, updateData } from './collection';
import { InfiniteData } from '@tanstack/react-query';
export const addSharedLink = (
data: InfiniteData<SharedLinkListResponse>,
newSharedLink: TSharedLink,
): SharedLinkListData => {
return addData<SharedLinkListResponse, TSharedLink>(
data,
InfiniteCollections.SHARED_LINKS,
newSharedLink,
(page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId),
);
};
export const updateSharedLink = (
data: InfiniteData<SharedLinkListResponse>,
newSharedLink: TSharedLink,
): SharedLinkListData => {
return updateData<SharedLinkListResponse, TSharedLink>(
data,
InfiniteCollections.SHARED_LINKS,
newSharedLink,
(page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId),
);
};
export const deleteSharedLink = (data: SharedLinkListData, shareId: string): SharedLinkListData => {
return deleteData<SharedLinkListResponse, SharedLinkListData>(
data,
InfiniteCollections.SHARED_LINKS,
(page) => page.sharedLinks.findIndex((c) => c.shareId === shareId),
);
};

View file

@ -29,6 +29,7 @@ module.exports = {
}, },
}, },
animation: { animation: {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out',
}, },
@ -83,6 +84,7 @@ module.exports = {
'surface-tertiary-alt': 'var(--surface-tertiary-alt)', 'surface-tertiary-alt': 'var(--surface-tertiary-alt)',
'surface-dialog': 'var(--surface-dialog)', 'surface-dialog': 'var(--surface-dialog)',
'surface-submit': 'var(--surface-submit)', 'surface-submit': 'var(--surface-submit)',
'surface-submit-hover': 'var(--surface-submit-hover)',
'border-light': 'var(--border-light)', 'border-light': 'var(--border-light)',
'border-medium': 'var(--border-medium)', 'border-medium': 'var(--border-medium)',
'border-medium-alt': 'var(--border-medium-alt)', 'border-medium-alt': 'var(--border-medium-alt)',

9
package-lock.json generated
View file

@ -933,6 +933,7 @@
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"match-sorter": "^6.3.4", "match-sorter": "^6.3.4",
"msedge-tts": "^1.3.4", "msedge-tts": "^1.3.4",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2", "rc-input-number": "^7.4.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.2", "react-avatar-editor": "^13.0.2",
@ -30206,6 +30207,14 @@
} }
] ]
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

View file

@ -14,10 +14,20 @@ export const messages = (conversationId: string, messageId?: string) =>
const shareRoot = '/api/share'; const shareRoot = '/api/share';
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`; export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const getSharedLinks = (pageNumber: string, isPublic: boolean) => export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
`${shareRoot}?pageNumber=${pageNumber}&isPublic=${isPublic}`; export const getSharedLinks = (
export const createSharedLink = shareRoot; pageSize: number,
export const updateSharedLink = shareRoot; isPublic: boolean,
sortBy: 'title' | 'createdAt',
sortDirection: 'asc' | 'desc',
search?: string,
cursor?: string,
) =>
`${shareRoot}?pageSize=${pageSize}&isPublic=${isPublic}&sortBy=${sortBy}&sortDirection=${sortDirection}${
search ? `&search=${search}` : ''
}${cursor ? `&cursor=${cursor}` : ''}`;
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;
export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`;
const keysEndpoint = '/api/keys'; const keysEndpoint = '/api/keys';

View file

@ -41,27 +41,29 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
return request.get(endpoints.shareMessages(shareId)); return request.get(endpoints.shareMessages(shareId));
} }
export const listSharedLinks = ( export const listSharedLinks = async (
params?: q.SharedLinkListParams, params: q.SharedLinksListParams,
): Promise<q.SharedLinksResponse> => { ): Promise<q.SharedLinksResponse> => {
const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided const { pageSize, isPublic, sortBy, sortDirection, search, cursor } = params;
const isPublic = params?.isPublic ?? true; // Default to true if not provided
return request.get(endpoints.getSharedLinks(pageNumber, isPublic)); return request.get(
endpoints.getSharedLinks(pageSize, isPublic, sortBy, sortDirection, search, cursor),
);
}; };
export function getSharedLink(shareId: string): Promise<t.TSharedLinkResponse> { export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetResponse> {
return request.get(endpoints.shareMessages(shareId)); return request.get(endpoints.getSharedLink(conversationId));
} }
export function createSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> { export function createSharedLink(conversationId: string): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink, payload); return request.post(endpoints.createSharedLink(conversationId));
} }
export function updateSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> { export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {
return request.patch(endpoints.updateSharedLink, payload); return request.patch(endpoints.updateSharedLink(shareId));
} }
export function deleteSharedLink(shareId: string): Promise<t.TDeleteSharedLinkResponse> { export function deleteSharedLink(shareId: string): Promise<m.TDeleteSharedLinkResponse> {
return request.delete(endpoints.shareMessages(shareId)); return request.delete(endpoints.shareMessages(shareId));
} }

View file

@ -75,6 +75,29 @@ export const useGetSharedMessages = (
); );
}; };
export const useGetSharedLinkQuery = (
conversationId: string,
config?: UseQueryOptions<t.TSharedLinkGetResponse>,
): QueryObserverResult<t.TSharedLinkGetResponse> => {
const queryClient = useQueryClient();
return useQuery<t.TSharedLinkGetResponse>(
[QueryKeys.sharedLinks, conversationId],
() => dataService.getSharedLink(conversationId),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
onSuccess: (data) => {
queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], {
conversationId: data.conversationId,
shareId: data.shareId,
});
},
...config,
},
);
};
export const useGetUserBalance = ( export const useGetUserBalance = (
config?: UseQueryOptions<string>, config?: UseQueryOptions<string>,
): QueryObserverResult<string> => { ): QueryObserverResult<string> => {
@ -306,7 +329,9 @@ export const useRegisterUserMutation = (
options?: m.RegistrationOptions, options?: m.RegistrationOptions,
): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => { ): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation((payload: t.TRegisterUser) => dataService.register(payload), { return useMutation<t.TRegisterUserResponse, t.TError, t.TRegisterUser>(
(payload: t.TRegisterUser) => dataService.register(payload),
{
...options, ...options,
onSuccess: (...args) => { onSuccess: (...args) => {
queryClient.invalidateQueries([QueryKeys.user]); queryClient.invalidateQueries([QueryKeys.user]);
@ -314,7 +339,8 @@ export const useRegisterUserMutation = (
options.onSuccess(...args); options.onSuccess(...args);
} }
}, },
}); },
);
}; };
export const useRefreshTokenMutation = (): UseMutationResult< export const useRefreshTokenMutation = (): UseMutationResult<

View file

@ -719,13 +719,12 @@ export const tSharedLinkSchema = z.object({
conversationId: z.string(), conversationId: z.string(),
shareId: z.string(), shareId: z.string(),
messages: z.array(z.string()), messages: z.array(z.string()),
isAnonymous: z.boolean(),
isPublic: z.boolean(), isPublic: z.boolean(),
isVisible: z.boolean(),
title: z.string(), title: z.string(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
export type TSharedLink = z.infer<typeof tSharedLinkSchema>; export type TSharedLink = z.infer<typeof tSharedLinkSchema>;
export const tConversationTagSchema = z.object({ export const tConversationTagSchema = z.object({

View file

@ -170,15 +170,17 @@ export type TArchiveConversationResponse = TConversation;
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & { export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
messages: TMessage[]; messages: TMessage[];
}; };
export type TSharedLinkRequest = Partial<
Omit<TSharedLink, 'messages' | 'createdAt' | 'updatedAt'>
> & {
conversationId: string;
};
export type TSharedLinkResponse = TSharedLink; export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>;
export type TSharedLinksResponse = TSharedLink[];
export type TDeleteSharedLinkResponse = TSharedLink; export type TUpdateShareLinkRequest = Pick<TSharedLink, 'shareId'>;
export type TSharedLinkResponse = Pick<TSharedLink, 'shareId'> &
Pick<TConversation, 'conversationId'>;
export type TSharedLinkGetResponse = TSharedLinkResponse & {
success: boolean;
};
// type for getting conversation tags // type for getting conversation tags
export type TConversationTagsResponse = TConversationTag[]; export type TConversationTagsResponse = TConversationTag[];
@ -203,12 +205,10 @@ export type TDuplicateConvoRequest = {
conversationId?: string; conversationId?: string;
}; };
export type TDuplicateConvoResponse = export type TDuplicateConvoResponse = {
| {
conversation: TConversation; conversation: TConversation;
messages: TMessage[]; messages: TMessage[];
} };
| undefined;
export type TForkConvoRequest = { export type TForkConvoRequest = {
messageId: string; messageId: string;

View file

@ -24,6 +24,12 @@ export type MutationOptions<
onSuccess?: (data: Response, variables: Request, context?: Context) => void; onSuccess?: (data: Response, variables: Request, context?: Context) => void;
onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>; onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>;
onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void; onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void;
onSettled?: (
data: Response | undefined,
error: Error | null,
variables: Request,
context?: Context,
) => void;
}; };
export type TGenTitleRequest = { export type TGenTitleRequest = {
@ -186,7 +192,12 @@ export type ArchiveConvoOptions = MutationOptions<
types.TArchiveConversationRequest types.TArchiveConversationRequest
>; >;
export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>; export type DeleteSharedLinkContext = { previousQueries?: Map<string, TDeleteSharedLinkResponse> };
export type DeleteSharedLinkOptions = MutationOptions<
TDeleteSharedLinkResponse,
{ shareId: string },
DeleteSharedLinkContext
>;
export type TUpdatePromptContext = export type TUpdatePromptContext =
| { | {
@ -298,3 +309,9 @@ export type ToolCallMutationOptions<T extends ToolId> = MutationOptions<
ToolCallResponse, ToolCallResponse,
ToolParams<T> ToolParams<T>
>; >;
export type TDeleteSharedLinkResponse = {
success: boolean;
shareId: string;
message: string;
};

View file

@ -41,23 +41,34 @@ export type ConversationUpdater = (
export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & { export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
messages: s.TMessage[]; messages: s.TMessage[];
}; };
export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & {
isPublic?: boolean; export interface SharedLinksListParams {
pageSize: number;
isPublic: boolean;
sortBy: 'title' | 'createdAt';
sortDirection: 'asc' | 'desc';
search?: string;
cursor?: string;
}
export type SharedLinkItem = {
shareId: string;
title: string;
isPublic: boolean;
createdAt: Date;
conversationId: string;
}; };
export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & { export interface SharedLinksResponse {
sharedLinks: s.TSharedLink[]; links: SharedLinkItem[];
}; nextCursor: string | null;
hasNextPage: boolean;
}
// Type for the response from the conversation list API export interface SharedLinkQueryData {
export type SharedLinkListResponse = { pages: SharedLinksResponse[];
sharedLinks: s.TSharedLink[]; pageParams: (string | null)[];
pageNumber: string; }
pageSize: string | number;
pages: string | number;
};
export type SharedLinkListData = InfiniteData<SharedLinkListResponse>;
export type AllPromptGroupsFilterRequest = { export type AllPromptGroupsFilterRequest = {
category: string; category: string;