Skip to content

Commit

Permalink
Merge branch 'main' into new/feat/edit-message
Browse files Browse the repository at this point in the history
  • Loading branch information
techwithanirudh authored May 18, 2024
2 parents 86b6b7c + f0e8cca commit a7d7372
Show file tree
Hide file tree
Showing 86 changed files with 5,312 additions and 811 deletions.
89 changes: 89 additions & 0 deletions api/models/Share.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const crypto = require('crypto');
const { getMessages } = require('./Message');
const SharedLink = require('./schema/shareSchema');
const logger = require('~/config/winston');

module.exports = {
SharedLink,
getSharedMessages: async (shareId) => {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();

if (!share || !share.conversationId || !share.isPublic) {
return null;
}

return share;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},

getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => {
const query = { user, isPublic };
try {
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const shares = await SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean();

return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
return { message: 'Error getting shares' };
}
},

createSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
return share;
}

try {
const shareId = crypto.randomUUID();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: true,
});
} catch (error) {
logger.error('[saveShareMessage] Error saving conversation', error);
return { message: 'Error saving conversation' };
}
},

updateSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
// update messages to the latest
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: false,
});
},

deleteSharedLink: async (user, { shareId }) => {
const share = await SharedLink.findOne({ shareId, user });
if (!share) {
return { message: 'Share not found' };
}
return await SharedLink.findOneAndDelete({ shareId, user });
},
};
38 changes: 38 additions & 0 deletions api/models/schema/shareSchema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const mongoose = require('mongoose');

const shareSchema = mongoose.Schema(
{
conversationId: {
type: String,
required: true,
},
title: {
type: String,
index: true,
},
user: {
type: String,
index: true,
},
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
shareId: {
type: String,
index: true,
},
isPublic: {
type: Boolean,
default: false,
},
isVisible: {
type: Boolean,
default: false,
},
isAnonymous: {
type: Boolean,
default: true,
},
},
{ timestamps: true },
);

module.exports = mongoose.model('SharedLink', shareSchema);
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const startServer = async () => {
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', validateImageRequest, routes.staticRoute);
app.use('/api/share', routes.share);

app.use((req, res) => {
res.status(404).sendFile(path.join(app.locals.paths.dist, 'index.html'));
Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const config = require('./config');
const assistants = require('./assistants');
const files = require('./files');
const staticRoute = require('./static');
const share = require('./share');

module.exports = {
search,
Expand All @@ -40,4 +41,5 @@ module.exports = {
assistants,
files,
staticRoute,
share,
};
75 changes: 75 additions & 0 deletions api/server/routes/share.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const express = require('express');

const {
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();

/**
* Shared messages
* this route does not require authentication
*/
router.get('/:shareId', async (req, res) => {
const share = await getSharedMessages(req.params.shareId);

if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
});

/**
* Shared links
*/
router.get('/', requireJwtAuth, async (req, res) => {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);

if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}

let pageSize = req.query.pageSize || 25;
pageSize = parseInt(pageSize, 10);

if (isNaN(pageSize) || pageSize < 1) {
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));
});

router.post('/', requireJwtAuth, async (req, res) => {
const created = await createSharedLink(req.user.id, req.body);
if (created) {
res.status(200).json(created);
} else {
res.status(404).end();
}
});

router.patch('/', requireJwtAuth, async (req, res) => {
const updated = await updateSharedLink(req.user.id, req.body);
if (updated) {
res.status(200).json(updated);
} else {
res.status(404).end();
}
});

router.delete('/:shareId', requireJwtAuth, async (req, res) => {
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId });
if (deleted) {
res.status(200).json(deleted);
} else {
res.status(404).end();
}
});

module.exports = router;
1 change: 1 addition & 0 deletions api/strategies/openidStrategy.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const fetch = require('node-fetch');
const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
Expand Down
5 changes: 5 additions & 0 deletions client/src/Providers/ShareContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext, useContext } from 'react';
type TShareContext = { isSharedConvo?: boolean };

export const ShareContext = createContext<TShareContext>({} as TShareContext);
export const useShareContext = () => useContext(ShareContext);
1 change: 1 addition & 0 deletions client/src/Providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { default as ToastProvider } from './ToastContext';
export { default as AssistantsProvider } from './AssistantsContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';
Expand Down
63 changes: 63 additions & 0 deletions client/src/components/Chat/ExportAndShareMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Upload } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import DropDownMenu from '../Conversations/DropDownMenu';
import ShareButton from '../Conversations/ShareButton';
import HoverToggle from '../Conversations/HoverToggle';
import ExportButton from './ExportButton';
import store from '~/store';

export default function ExportAndShareMenu() {
const location = useLocation();

const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [isPopoverActive, setIsPopoverActive] = useState(false);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}

const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';

if (!exportable) {
return <></>;
}

const isActiveConvo = exportable;

return (
<HoverToggle
isActiveConvo={!!isActiveConvo}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
>
<DropDownMenu
icon={<Upload />}
tooltip="Export/Share"
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
>
{conversation && conversation.conversationId && (
<>
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
</>
)}
</DropDownMenu>
</HoverToggle>
);
}
67 changes: 20 additions & 47 deletions client/src/components/Chat/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,41 @@
import React from 'react';

import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import { Download } from 'lucide-react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { Upload } from 'lucide-react';
import { useLocalize } from '~/hooks';
import { ExportModal } from '../Nav';
import { useRecoilValue } from 'recoil';
import store from '~/store';

function ExportButton() {
function ExportButton({
conversation,
setPopoverActive,
}: {
conversation: TConversation;
setPopoverActive: (value: boolean) => void;
}) {
const localize = useLocalize();
const location = useLocation();

const [showExports, setShowExports] = useState(false);

const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);

let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}

const clickHandler = () => {
if (exportable) {
setShowExports(true);
}
setShowExports(true);
};

const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';
const onOpenChange = (value: boolean) => {
setShowExports(value);
setPopoverActive(value);
};

return (
<>
{exportable && (
<div className="flex gap-1 gap-2 pr-1">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="btn btn-neutral btn-small relative flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg"
onClick={clickHandler}
>
<div className="flex w-full items-center justify-center gap-2">
<Download size={16} />
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={5}>
{localize('com_nav_export_conversation')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<button
onClick={clickHandler}
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
>
<Upload size={16} /> {localize('com_nav_export')}
</button>
{showExports && (
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
<ExportModal open={showExports} onOpenChange={onOpenChange} conversation={conversation} />
)}
</>
);
Expand Down
Loading

0 comments on commit a7d7372

Please sign in to comment.