🔗 fix: Add branch-specific shared links (targetMessageId) (#10016)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* feat: Enhance shared link functionality with target message support

* refactor: Remove comment on compound index in share schema

* chore: Reorganize imports in ShareButton component for clarity

* refactor: Integrate Recoil for latest message tracking in ShareButton component

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-10-10 14:42:05 +02:00 committed by GitHub
parent ded3f2e998
commit 5566cc499e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 129 additions and 12 deletions

View file

@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try { 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) { if (created) {
res.status(200).json(created); res.status(200).json(created);
} else { } else {

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react'; import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; 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 { useLocalize, useCopyToClipboard } from '~/hooks';
import SharedLinkButton from './SharedLinkButton'; import SharedLinkButton from './SharedLinkButton';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store';
export default function ShareButton({ export default function ShareButton({
conversationId, conversationId,
@ -24,8 +26,9 @@ export default function ShareButton({
const [showQR, setShowQR] = useState(false); const [showQR, setShowQR] = useState(false);
const [sharedLink, setSharedLink] = useState(''); const [sharedLink, setSharedLink] = useState('');
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const copyLink = useCopyToClipboard({ text: sharedLink }); const copyLink = useCopyToClipboard({ text: sharedLink });
const latestMessage = useRecoilValue(store.latestMessageFamily(0));
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
useEffect(() => { useEffect(() => {
if (share?.shareId !== undefined) { if (share?.shareId !== undefined) {
@ -39,6 +42,7 @@ export default function ShareButton({
<SharedLinkButton <SharedLinkButton
share={share} share={share}
conversationId={conversationId} conversationId={conversationId}
targetMessageId={latestMessage?.messageId}
setShareDialogOpen={onOpenChange} setShareDialogOpen={onOpenChange}
showQR={showQR} showQR={showQR}
setShowQR={setShowQR} setShowQR={setShowQR}

View file

@ -21,6 +21,7 @@ import { useLocalize } from '~/hooks';
export default function SharedLinkButton({ export default function SharedLinkButton({
share, share,
conversationId, conversationId,
targetMessageId,
setShareDialogOpen, setShareDialogOpen,
showQR, showQR,
setShowQR, setShowQR,
@ -28,6 +29,7 @@ export default function SharedLinkButton({
}: { }: {
share: TSharedLinkGetResponse | undefined; share: TSharedLinkGetResponse | undefined;
conversationId: string; conversationId: string;
targetMessageId?: string;
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
showQR: boolean; showQR: boolean;
setShowQR: (showQR: boolean) => void; setShowQR: (showQR: boolean) => void;
@ -86,7 +88,7 @@ export default function SharedLinkButton({
}; };
const createShareLink = async () => { const createShareLink = async () => {
const share = await mutate({ conversationId }); const share = await mutate({ conversationId, targetMessageId });
const newLink = generateShareLink(share.shareId); const newLink = generateShareLink(share.shareId);
setSharedLink(newLink); setSharedLink(newLink);
}; };

View file

@ -168,18 +168,26 @@ export const useArchiveConvoMutation = (
}; };
export const useCreateSharedLinkMutation = ( export const useCreateSharedLinkMutation = (
options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>, options?: t.MutationOptions<
): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => { t.TCreateShareLinkRequest,
{ conversationId: string; targetMessageId?: string }
>,
): UseMutationResult<
t.TSharedLinkResponse,
unknown,
{ conversationId: string; targetMessageId?: string },
unknown
> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ..._options } = options || {}; const { onSuccess, ..._options } = options || {};
return useMutation( return useMutation(
({ conversationId }: { conversationId: string }) => { ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => {
if (!conversationId) { if (!conversationId) {
throw new Error('Conversation ID is required'); throw new Error('Conversation ID is required');
} }
return dataService.createSharedLink(conversationId); return dataService.createSharedLink(conversationId, targetMessageId);
}, },
{ {
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {

View file

@ -42,8 +42,11 @@ export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetR
return request.get(endpoints.getSharedLink(conversationId)); return request.get(endpoints.getSharedLink(conversationId));
} }
export function createSharedLink(conversationId: string): Promise<t.TSharedLinkResponse> { export function createSharedLink(
return request.post(endpoints.createSharedLink(conversationId)); conversationId: string,
targetMessageId?: string,
): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink(conversationId), { targetMessageId });
} }
export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> { export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {

View file

@ -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<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);
}
// 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<string>();
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage];
const results = new Set<t.IMessage>(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 */ /** Factory function that takes mongoose instance and returns the methods */
export function createShareMethods(mongoose: typeof import('mongoose')) { export function createShareMethods(mongoose: typeof import('mongoose')) {
/** /**
@ -102,6 +173,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
return null; 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 newConvoId = anonymizeConvoId(share.conversationId);
const result: t.SharedMessagesResult = { const result: t.SharedMessagesResult = {
shareId: share.shareId || shareId, shareId: share.shareId || shareId,
@ -110,7 +187,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
createdAt: share.createdAt, createdAt: share.createdAt,
updatedAt: share.updatedAt, updatedAt: share.updatedAt,
conversationId: newConvoId, conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId), messages: anonymizeMessages(messagesToShare, newConvoId),
}; };
return result; return result;
@ -239,6 +316,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
async function createSharedLink( async function createSharedLink(
user: string, user: string,
conversationId: string, conversationId: string,
targetMessageId?: string,
): Promise<t.CreateShareResult> { ): Promise<t.CreateShareResult> {
if (!user || !conversationId) { if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); 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 Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods;
const [existingShare, conversationMessages] = await Promise.all([ const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, user, isPublic: true }) SharedLink.findOne({
conversationId,
user,
isPublic: true,
...(targetMessageId && { targetMessageId }),
})
.select('-_id -__v -user') .select('-_id -__v -user')
.lean() as Promise<t.ISharedLink | null>, .lean() as Promise<t.ISharedLink | null>,
Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), 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', { logger.error('[createSharedLink] Share already exists', {
user, user,
conversationId, conversationId,
targetMessageId,
}); });
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) { } else if (existingShare) {
await SharedLink.deleteOne({ conversationId, user }); await SharedLink.deleteOne({
conversationId,
user,
...(targetMessageId && { targetMessageId }),
});
} }
const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as {
@ -291,6 +379,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
messages: conversationMessages, messages: conversationMessages,
title, title,
user, user,
...(targetMessageId && { targetMessageId }),
}); });
return { shareId, conversationId }; return { shareId, conversationId };
@ -302,6 +391,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
user, user,
conversationId, conversationId,
targetMessageId,
}); });
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
} }

View file

@ -6,6 +6,7 @@ export interface ISharedLink extends Document {
user?: string; user?: string;
messages?: Types.ObjectId[]; messages?: Types.ObjectId[];
shareId?: string; shareId?: string;
targetMessageId?: string;
isPublic: boolean; isPublic: boolean;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
@ -30,6 +31,11 @@ const shareSchema: Schema<ISharedLink> = new Schema(
type: String, type: String,
index: true, index: true,
}, },
targetMessageId: {
type: String,
required: false,
index: true,
},
isPublic: { isPublic: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -38,4 +44,6 @@ const shareSchema: Schema<ISharedLink> = new Schema(
{ timestamps: true }, { timestamps: true },
); );
shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 });
export default shareSchema; export default shareSchema;

View file

@ -8,6 +8,7 @@ export interface ISharedLink {
user?: string; user?: string;
messages?: Types.ObjectId[]; messages?: Types.ObjectId[];
shareId?: string; shareId?: string;
targetMessageId?: string;
isPublic: boolean; isPublic: boolean;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;