mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🔗 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
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:
parent
ded3f2e998
commit
5566cc499e
8 changed files with 129 additions and 12 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue