| | import { nanoid } from 'nanoid'; |
| | import { Constants } from 'librechat-data-provider'; |
| | import type { FilterQuery, Model } from 'mongoose'; |
| | import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; |
| | import type * as t from '~/types'; |
| | import logger from '~/config/winston'; |
| |
|
| | class ShareServiceError extends Error { |
| | code: string; |
| | constructor(message: string, code: string) { |
| | super(message); |
| | this.name = 'ShareServiceError'; |
| | this.code = code; |
| | } |
| | } |
| |
|
| | function memoizedAnonymizeId(prefix: string) { |
| | const memo = new Map<string, string>(); |
| | return (id: string) => { |
| | if (!memo.has(id)) { |
| | memo.set(id, `${prefix}_${nanoid()}`); |
| | } |
| | return memo.get(id) as string; |
| | }; |
| | } |
| |
|
| | const anonymizeConvoId = memoizedAnonymizeId('convo'); |
| | const anonymizeAssistantId = memoizedAnonymizeId('a'); |
| | const anonymizeMessageId = (id: string) => |
| | id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id); |
| |
|
| | function anonymizeConvo(conversation: Partial<t.IConversation> & Partial<t.ISharedLink>) { |
| | if (!conversation) { |
| | return null; |
| | } |
| |
|
| | const newConvo = { ...conversation }; |
| | if (newConvo.assistant_id) { |
| | newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id); |
| | } |
| | return newConvo; |
| | } |
| |
|
| | function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.IMessage[] { |
| | if (!Array.isArray(messages)) { |
| | return []; |
| | } |
| |
|
| | const idMap = new Map<string, string>(); |
| | return messages.map((message) => { |
| | const newMessageId = anonymizeMessageId(message.messageId); |
| | idMap.set(message.messageId, newMessageId); |
| |
|
| | type MessageAttachment = { |
| | messageId?: string; |
| | conversationId?: string; |
| | [key: string]: unknown; |
| | }; |
| |
|
| | const anonymizedAttachments = (message.attachments as MessageAttachment[])?.map( |
| | (attachment) => { |
| | return { |
| | ...attachment, |
| | messageId: newMessageId, |
| | conversationId: newConvoId, |
| | }; |
| | }, |
| | ); |
| |
|
| | return { |
| | ...message, |
| | messageId: newMessageId, |
| | parentMessageId: |
| | idMap.get(message.parentMessageId || '') || |
| | anonymizeMessageId(message.parentMessageId || ''), |
| | conversationId: newConvoId, |
| | model: message.model?.startsWith('asst_') |
| | ? anonymizeAssistantId(message.model) |
| | : message.model, |
| | attachments: anonymizedAttachments, |
| | } as t.IMessage; |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string): t.IMessage[] { |
| | if (!messages || messages.length === 0) { |
| | return []; |
| | } |
| |
|
| | |
| | if (messages.length === 1 && messages[0]?.messageId === targetMessageId) { |
| | return messages; |
| | } |
| |
|
| | |
| | const parentToChildrenMap = new Map<string, t.IMessage[]>(); |
| | for (const message of messages) { |
| | const parentId = message.parentMessageId || Constants.NO_PARENT; |
| | if (!parentToChildrenMap.has(parentId)) { |
| | parentToChildrenMap.set(parentId, []); |
| | } |
| | parentToChildrenMap.get(parentId)?.push(message); |
| | } |
| |
|
| | |
| | const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); |
| | if (!targetMessage) { |
| | |
| | return messages; |
| | } |
| |
|
| | const visited = new Set<string>(); |
| | const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; |
| | let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage]; |
| | const results = new Set<t.IMessage>(currentLevel); |
| |
|
| | |
| | if ( |
| | currentLevel.some((msg) => msg.messageId === targetMessageId) && |
| | targetMessage.parentMessageId === Constants.NO_PARENT |
| | ) { |
| | return Array.from(results); |
| | } |
| |
|
| | |
| | let targetFound = false; |
| | while (!targetFound && currentLevel.length > 0) { |
| | const nextLevel: t.IMessage[] = []; |
| | for (const node of currentLevel) { |
| | if (visited.has(node.messageId)) { |
| | continue; |
| | } |
| | visited.add(node.messageId); |
| | const children = parentToChildrenMap.get(node.messageId) || []; |
| | for (const child of children) { |
| | if (visited.has(child.messageId)) { |
| | continue; |
| | } |
| | nextLevel.push(child); |
| | results.add(child); |
| | if (child.messageId === targetMessageId) { |
| | targetFound = true; |
| | } |
| | } |
| | } |
| | currentLevel = nextLevel; |
| | } |
| |
|
| | return Array.from(results); |
| | } |
| |
|
| | |
| | export function createShareMethods(mongoose: typeof import('mongoose')) { |
| | |
| | |
| | |
| | async function getSharedMessages(shareId: string): Promise<t.SharedMessagesResult | null> { |
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const share = (await SharedLink.findOne({ shareId, isPublic: true }) |
| | .populate({ |
| | path: 'messages', |
| | select: '-_id -__v -user', |
| | }) |
| | .select('-_id -__v -user') |
| | .lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null; |
| |
|
| | if (!share?.conversationId || !share.isPublic) { |
| | return null; |
| | } |
| |
|
| | |
| | let messagesToShare: t.IMessage[] = share.messages; |
| | if (share.targetMessageId) { |
| | messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId); |
| | } |
| |
|
| | const newConvoId = anonymizeConvoId(share.conversationId); |
| | const result: t.SharedMessagesResult = { |
| | shareId: share.shareId || shareId, |
| | title: share.title, |
| | isPublic: share.isPublic, |
| | createdAt: share.createdAt, |
| | updatedAt: share.updatedAt, |
| | conversationId: newConvoId, |
| | messages: anonymizeMessages(messagesToShare, newConvoId), |
| | }; |
| |
|
| | return result; |
| | } catch (error) { |
| | logger.error('[getSharedMessages] Error getting share link', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | shareId, |
| | }); |
| | throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function getSharedLinks( |
| | user: string, |
| | pageParam?: Date, |
| | pageSize: number = 10, |
| | isPublic: boolean = true, |
| | sortBy: string = 'createdAt', |
| | sortDirection: string = 'desc', |
| | search?: string, |
| | ): Promise<t.SharedLinksResult> { |
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; |
| | const query: FilterQuery<t.ISharedLink> = { user, isPublic }; |
| |
|
| | 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, { |
| | filter: `user = "${user}"`, |
| | }); |
| |
|
| | 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 instanceof Error ? searchError.message : 'Unknown error', |
| | user, |
| | }); |
| | return { |
| | links: [], |
| | nextCursor: undefined, |
| | hasNextPage: false, |
| | }; |
| | } |
| | } |
| |
|
| | const sort: Record<string, 1 | -1> = {}; |
| | sort[sortBy] = sortDirection === 'desc' ? -1 : 1; |
| |
|
| | 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 as keyof t.ISharedLink] as Date) |
| | : undefined; |
| |
|
| | return { |
| | links: links.map((link) => ({ |
| | shareId: link.shareId || '', |
| | title: link?.title || 'Untitled', |
| | isPublic: link.isPublic, |
| | createdAt: link.createdAt || new Date(), |
| | conversationId: link.conversationId, |
| | })), |
| | nextCursor, |
| | hasNextPage, |
| | }; |
| | } catch (error) { |
| | logger.error('[getSharedLinks] Error getting shares', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | }); |
| | throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function deleteAllSharedLinks(user: string): Promise<t.DeleteAllSharesResult> { |
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const result = await SharedLink.deleteMany({ user }); |
| | return { |
| | message: 'All shared links deleted successfully', |
| | deletedCount: result.deletedCount, |
| | }; |
| | } catch (error) { |
| | logger.error('[deleteAllSharedLinks] Error deleting shared links', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | }); |
| | throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function deleteConvoSharedLink( |
| | user: string, |
| | conversationId: string, |
| | ): Promise<t.DeleteAllSharesResult> { |
| | if (!user || !conversationId) { |
| | throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); |
| | } |
| |
|
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const result = await SharedLink.deleteMany({ user, conversationId }); |
| | return { |
| | message: 'Shared links deleted successfully', |
| | deletedCount: result.deletedCount, |
| | }; |
| | } catch (error) { |
| | logger.error('[deleteConvoSharedLink] Error deleting shared links', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | conversationId, |
| | }); |
| | throw new ShareServiceError('Error deleting shared links', 'SHARE_DELETE_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function createSharedLink( |
| | user: string, |
| | conversationId: string, |
| | targetMessageId?: string, |
| | ): Promise<t.CreateShareResult> { |
| | if (!user || !conversationId) { |
| | throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); |
| | } |
| | try { |
| | const Message = mongoose.models.Message as SchemaWithMeiliMethods; |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; |
| |
|
| | const [existingShare, conversationMessages] = await Promise.all([ |
| | SharedLink.findOne({ |
| | conversationId, |
| | user, |
| | isPublic: true, |
| | ...(targetMessageId && { targetMessageId }), |
| | }) |
| | .select('-_id -__v -user') |
| | .lean() as Promise<t.ISharedLink | null>, |
| | Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), |
| | ]); |
| |
|
| | if (existingShare && existingShare.isPublic) { |
| | logger.error('[createSharedLink] Share already exists', { |
| | user, |
| | conversationId, |
| | targetMessageId, |
| | }); |
| | throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); |
| | } else if (existingShare) { |
| | await SharedLink.deleteOne({ |
| | conversationId, |
| | user, |
| | ...(targetMessageId && { targetMessageId }), |
| | }); |
| | } |
| |
|
| | const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { |
| | title?: string; |
| | } | null; |
| |
|
| | |
| | if (!conversation) { |
| | throw new ShareServiceError( |
| | 'Conversation not found or access denied', |
| | 'CONVERSATION_NOT_FOUND', |
| | ); |
| | } |
| |
|
| | |
| | if (!conversationMessages || conversationMessages.length === 0) { |
| | throw new ShareServiceError('No messages to share', 'NO_MESSAGES'); |
| | } |
| |
|
| | const title = conversation.title || 'Untitled'; |
| |
|
| | const shareId = nanoid(); |
| | await SharedLink.create({ |
| | shareId, |
| | conversationId, |
| | messages: conversationMessages, |
| | title, |
| | user, |
| | ...(targetMessageId && { targetMessageId }), |
| | }); |
| |
|
| | return { shareId, conversationId }; |
| | } catch (error) { |
| | if (error instanceof ShareServiceError) { |
| | throw error; |
| | } |
| | logger.error('[createSharedLink] Error creating shared link', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | conversationId, |
| | targetMessageId, |
| | }); |
| | throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function getSharedLink( |
| | user: string, |
| | conversationId: string, |
| | ): Promise<t.GetShareLinkResult> { |
| | if (!user || !conversationId) { |
| | throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); |
| | } |
| |
|
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const share = (await SharedLink.findOne({ conversationId, user, isPublic: true }) |
| | .select('shareId -_id') |
| | .lean()) as { shareId?: string } | null; |
| |
|
| | if (!share) { |
| | return { shareId: null, success: false }; |
| | } |
| |
|
| | return { shareId: share.shareId || null, success: true }; |
| | } catch (error) { |
| | logger.error('[getSharedLink] Error getting shared link', { |
| | error: error instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | conversationId, |
| | }); |
| | throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function updateSharedLink(user: string, shareId: string): Promise<t.UpdateShareResult> { |
| | if (!user || !shareId) { |
| | throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); |
| | } |
| |
|
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | const Message = mongoose.models.Message as SchemaWithMeiliMethods; |
| | const share = (await SharedLink.findOne({ shareId, user }) |
| | .select('-_id -__v -user') |
| | .lean()) as t.ISharedLink | null; |
| |
|
| | if (!share) { |
| | throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND'); |
| | } |
| |
|
| | const updatedMessages = await Message.find({ conversationId: share.conversationId, user }) |
| | .sort({ createdAt: 1 }) |
| | .lean(); |
| |
|
| | 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()) as t.ISharedLink | null; |
| |
|
| | 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 instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | shareId, |
| | }); |
| | throw new ShareServiceError( |
| | error instanceof ShareServiceError ? error.message : 'Error updating shared link', |
| | error instanceof ShareServiceError ? error.code : 'SHARE_UPDATE_ERROR', |
| | ); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function deleteSharedLink( |
| | user: string, |
| | shareId: string, |
| | ): Promise<t.DeleteShareResult | null> { |
| | if (!user || !shareId) { |
| | throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); |
| | } |
| |
|
| | try { |
| | const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>; |
| | 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 instanceof Error ? error.message : 'Unknown error', |
| | user, |
| | shareId, |
| | }); |
| | throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR'); |
| | } |
| | } |
| |
|
| | |
| | return { |
| | getSharedLink, |
| | getSharedLinks, |
| | createSharedLink, |
| | updateSharedLink, |
| | deleteSharedLink, |
| | getSharedMessages, |
| | deleteAllSharedLinks, |
| | deleteConvoSharedLink, |
| | }; |
| | } |
| |
|
| | export type ShareMethods = ReturnType<typeof createShareMethods>; |
| |
|