diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 14c25271fc..6400b8b637 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - const created = await createSharedLink(req.user.id, req.params.conversationId); + const { targetMessageId } = req.body; + const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); if (created) { res.status(200).json(created); } else { diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 177dd5ae5f..46310268f0 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; @@ -6,6 +7,7 @@ import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client'; import { useLocalize, useCopyToClipboard } from '~/hooks'; import SharedLinkButton from './SharedLinkButton'; import { cn } from '~/utils'; +import store from '~/store'; export default function ShareButton({ conversationId, @@ -24,8 +26,9 @@ export default function ShareButton({ const [showQR, setShowQR] = useState(false); const [sharedLink, setSharedLink] = useState(''); const [isCopying, setIsCopying] = useState(false); - const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); const copyLink = useCopyToClipboard({ text: sharedLink }); + const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); useEffect(() => { if (share?.shareId !== undefined) { @@ -39,6 +42,7 @@ export default function ShareButton({ >; showQR: boolean; setShowQR: (showQR: boolean) => void; @@ -86,7 +88,7 @@ export default function SharedLinkButton({ }; const createShareLink = async () => { - const share = await mutate({ conversationId }); + const share = await mutate({ conversationId, targetMessageId }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 21fb765fb1..7abea71187 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -168,18 +168,26 @@ export const useArchiveConvoMutation = ( }; export const useCreateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions< + t.TCreateShareLinkRequest, + { conversationId: string; targetMessageId?: string } + >, +): UseMutationResult< + t.TSharedLinkResponse, + unknown, + { conversationId: string; targetMessageId?: string }, + unknown +> => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ conversationId }: { conversationId: string }) => { + ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { if (!conversationId) { throw new Error('Conversation ID is required'); } - return dataService.createSharedLink(conversationId); + return dataService.createSharedLink(conversationId, targetMessageId); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c45ab4e0b4..c7d1a1c052 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -42,8 +42,11 @@ export function getSharedLink(conversationId: string): Promise { - return request.post(endpoints.createSharedLink(conversationId)); +export function createSharedLink( + conversationId: string, + targetMessageId?: string, +): Promise { + return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); } export function updateSharedLink(shareId: string): Promise { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 8ff71fd718..11e893ff9c 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -82,6 +82,77 @@ function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.IMessa }); } +/** + * Filter messages up to and including the target message (branch-specific) + * Similar to getMessagesUpToTargetLevel from fork utilities + */ +function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string): t.IMessage[] { + if (!messages || messages.length === 0) { + return []; + } + + // If only one message and it's the target, return it + if (messages.length === 1 && messages[0]?.messageId === targetMessageId) { + return messages; + } + + // Create a map of parentMessageId to children messages + const parentToChildrenMap = new Map(); + for (const message of messages) { + const parentId = message.parentMessageId || Constants.NO_PARENT; + if (!parentToChildrenMap.has(parentId)) { + parentToChildrenMap.set(parentId, []); + } + parentToChildrenMap.get(parentId)?.push(message); + } + + // Find the target message + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + // If target not found, return all messages for backwards compatibility + return messages; + } + + const visited = new Set(); + const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; + let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage]; + const results = new Set(currentLevel); + + // Check if the target message is at the root level + if ( + currentLevel.some((msg) => msg.messageId === targetMessageId) && + targetMessage.parentMessageId === Constants.NO_PARENT + ) { + return Array.from(results); + } + + // Iterate level by level until the target is found + 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); +} + /** Factory function that takes mongoose instance and returns the methods */ export function createShareMethods(mongoose: typeof import('mongoose')) { /** @@ -102,6 +173,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { return null; } + // Filter messages based on targetMessageId if present (branch-specific sharing) + let messagesToShare = share.messages; + if (share.targetMessageId) { + messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId); + } + const newConvoId = anonymizeConvoId(share.conversationId); const result: t.SharedMessagesResult = { shareId: share.shareId || shareId, @@ -110,7 +187,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { createdAt: share.createdAt, updatedAt: share.updatedAt, conversationId: newConvoId, - messages: anonymizeMessages(share.messages, newConvoId), + messages: anonymizeMessages(messagesToShare, newConvoId), }; return result; @@ -239,6 +316,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function createSharedLink( user: string, conversationId: string, + targetMessageId?: string, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -249,7 +327,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; const [existingShare, conversationMessages] = await Promise.all([ - SharedLink.findOne({ conversationId, user, isPublic: true }) + SharedLink.findOne({ + conversationId, + user, + isPublic: true, + ...(targetMessageId && { targetMessageId }), + }) .select('-_id -__v -user') .lean() as Promise, Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), @@ -259,10 +342,15 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { 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 }); + await SharedLink.deleteOne({ + conversationId, + user, + ...(targetMessageId && { targetMessageId }), + }); } const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { @@ -291,6 +379,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { messages: conversationMessages, title, user, + ...(targetMessageId && { targetMessageId }), }); return { shareId, conversationId }; @@ -302,6 +391,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { error: error instanceof Error ? error.message : 'Unknown error', user, conversationId, + targetMessageId, }); throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); } diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 62347beb56..987dd10fc2 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -6,6 +6,7 @@ export interface ISharedLink extends Document { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date; @@ -30,6 +31,11 @@ const shareSchema: Schema = new Schema( type: String, index: true, }, + targetMessageId: { + type: String, + required: false, + index: true, + }, isPublic: { type: Boolean, default: true, @@ -38,4 +44,6 @@ const shareSchema: Schema = new Schema( { timestamps: true }, ); +shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); + export default shareSchema; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 3db1a360c6..8b54990cf4 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date;