From 202bc51f1510859bf373920bccf88ca7f5d3b3af Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 1 Jun 2026 23:02:21 -0400 Subject: [PATCH 01/47] feat: Add private chat projects --- api/server/experimental.js | 1 + api/server/index.js | 1 + api/server/routes/convos.js | 4 + api/server/routes/index.js | 2 + api/server/routes/projects.js | 144 ++++++ .../Conversations/Conversations.tsx | 6 +- client/src/components/Conversations/Convo.tsx | 1 + .../ConvoOptions/ConvoOptions.tsx | 91 +++- .../ConvoOptions/ProjectButton.tsx | 153 ++++++ .../Conversations/ConvoOptions/index.js | 1 + .../Conversations/ConvoOptions/index.ts | 1 + .../Conversations/ProjectConversations.tsx | 466 ++++++++++++++++++ .../components/Projects/ProjectChatList.tsx | 241 +++++++++ .../components/Projects/ProjectWorkspace.tsx | 174 +++++++ .../src/components/Projects/ProjectsView.tsx | 184 +++++++ client/src/components/Projects/index.ts | 3 + .../UnifiedSidebar/ConversationsSection.tsx | 83 +++- client/src/data-provider/Projects/index.ts | 3 + .../src/data-provider/Projects/mutations.ts | 79 +++ client/src/data-provider/Projects/queries.ts | 54 ++ client/src/data-provider/index.ts | 1 + client/src/data-provider/mutations.ts | 4 + client/src/data-provider/queries.ts | 5 +- client/src/locales/en/translation.json | 28 ++ client/src/routes/ChatRoute.tsx | 13 +- client/src/routes/index.tsx | 18 + client/src/utils/convos.ts | 48 +- packages/api/src/db/utils.ts | 1 + packages/data-provider/src/api-endpoints.ts | 11 + packages/data-provider/src/bedrock.ts | 3 + packages/data-provider/src/data-service.ts | 32 ++ packages/data-provider/src/keys.ts | 6 + packages/data-provider/src/schemas.spec.ts | 8 + packages/data-provider/src/schemas.ts | 9 + packages/data-provider/src/types.ts | 38 ++ packages/data-provider/src/types/queries.ts | 18 +- .../src/methods/chatProject.spec.ts | 157 ++++++ .../data-schemas/src/methods/chatProject.ts | 414 ++++++++++++++++ .../data-schemas/src/methods/conversation.ts | 33 +- packages/data-schemas/src/methods/index.ts | 4 + .../data-schemas/src/models/chatProject.ts | 12 + packages/data-schemas/src/models/index.ts | 2 + .../data-schemas/src/schema/chatProject.ts | 51 ++ packages/data-schemas/src/schema/convo.ts | 7 + packages/data-schemas/src/schema/index.ts | 1 + .../data-schemas/src/types/chatProject.ts | 17 + packages/data-schemas/src/types/convo.ts | 1 + packages/data-schemas/src/types/index.ts | 1 + 48 files changed, 2609 insertions(+), 26 deletions(-) create mode 100644 api/server/routes/projects.js create mode 100644 client/src/components/Conversations/ConvoOptions/ProjectButton.tsx create mode 100644 client/src/components/Conversations/ProjectConversations.tsx create mode 100644 client/src/components/Projects/ProjectChatList.tsx create mode 100644 client/src/components/Projects/ProjectWorkspace.tsx create mode 100644 client/src/components/Projects/ProjectsView.tsx create mode 100644 client/src/components/Projects/index.ts create mode 100644 client/src/data-provider/Projects/index.ts create mode 100644 client/src/data-provider/Projects/mutations.ts create mode 100644 client/src/data-provider/Projects/queries.ts create mode 100644 packages/data-schemas/src/methods/chatProject.spec.ts create mode 100644 packages/data-schemas/src/methods/chatProject.ts create mode 100644 packages/data-schemas/src/models/chatProject.ts create mode 100644 packages/data-schemas/src/schema/chatProject.ts create mode 100644 packages/data-schemas/src/types/chatProject.ts diff --git a/api/server/experimental.js b/api/server/experimental.js index 8fd94c67232..c20f6813cac 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -382,6 +382,7 @@ if (cluster.isMaster) { app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); + app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); diff --git a/api/server/index.js b/api/server/index.js index bd2d260e762..d96180cc4ec 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -201,6 +201,7 @@ const startServer = async () => { app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); + app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index dc59482afa7..35bbacacf3f 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -36,6 +36,9 @@ router.get('/', async (req, res) => { const search = req.query.search ? decodeURIComponent(req.query.search) : undefined; const sortBy = req.query.sortBy || 'updatedAt'; const sortDirection = req.query.sortDirection || 'desc'; + const projectId = Array.isArray(req.query.projectId) + ? req.query.projectId[0] + : req.query.projectId; let tags; if (req.query.tags) { @@ -51,6 +54,7 @@ router.get('/', async (req, res) => { search, sortBy, sortDirection, + projectId, }); res.status(200).json(result); } catch (error) { diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6694e47f23e..3322dd57e35 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -12,6 +12,7 @@ const staticRoute = require('./static'); const messages = require('./messages'); const memories = require('./memories'); const presets = require('./presets'); +const projects = require('./projects'); const prompts = require('./prompts'); const skills = require('./skills'); const balance = require('./balance'); @@ -59,6 +60,7 @@ module.exports = { config, models, prompts, + projects, skills, actions, presets, diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js new file mode 100644 index 00000000000..c77b11e1b9f --- /dev/null +++ b/api/server/routes/projects.js @@ -0,0 +1,144 @@ +const express = require('express'); +const { isValidObjectIdString, logger } = require('@librechat/data-schemas'); +const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const db = require('~/models'); + +const router = express.Router(); +router.use(requireJwtAuth); + +const PROJECT_NOT_FOUND = 'Project not found'; +const CONVERSATION_NOT_FOUND = 'Conversation not found'; + +const normalizeString = (value) => (typeof value === 'string' ? value.trim() : ''); + +const normalizeLimit = (value) => { + const limit = parseInt(value, 10); + if (!Number.isFinite(limit)) { + return 25; + } + return Math.min(Math.max(limit, 1), 100); +}; + +router.get('/', async (req, res) => { + try { + const result = await db.listChatProjects(req.user.id, { + cursor: req.query.cursor, + limit: normalizeLimit(req.query.limit), + sortBy: req.query.sortBy, + sortDirection: req.query.sortDirection, + search: req.query.search, + }); + res.status(200).json(result); + } catch (error) { + logger.error('[projects] Error listing projects', error); + res.status(500).json({ error: 'Error listing projects' }); + } +}); + +router.post('/', async (req, res) => { + const name = normalizeString(req.body?.name); + const description = typeof req.body?.description === 'string' ? req.body.description : ''; + + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + try { + const project = await db.createChatProject(req.user.id, { name, description }); + res.status(201).json(project); + } catch (error) { + logger.error('[projects] Error creating project', error); + res.status(500).json({ error: 'Error creating project' }); + } +}); + +router.put('/conversations/:conversationId', async (req, res) => { + const { conversationId } = req.params; + const projectId = req.body?.projectId ?? null; + + if (projectId !== null && typeof projectId !== 'string') { + return res.status(400).json({ error: 'projectId must be a string or null' }); + } + + try { + const result = await db.assignConversationToProject(req.user.id, conversationId, projectId); + if (!result) { + return res.status(404).json({ error: CONVERSATION_NOT_FOUND }); + } + res.status(200).json(result); + } catch (error) { + if (error?.message === PROJECT_NOT_FOUND) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + logger.error('[projects] Error assigning conversation to project', error); + res.status(500).json({ error: 'Error assigning conversation to project' }); + } +}); + +router.get('/:projectId', async (req, res) => { + const { projectId } = req.params; + if (!isValidObjectIdString(projectId)) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + + try { + const project = await db.getChatProject(req.user.id, projectId); + if (!project) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + res.status(200).json(project); + } catch (error) { + logger.error('[projects] Error getting project', error); + res.status(500).json({ error: 'Error getting project' }); + } +}); + +router.patch('/:projectId', async (req, res) => { + const { projectId } = req.params; + if (!isValidObjectIdString(projectId)) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + + const input = {}; + if (req.body?.name !== undefined) { + const name = normalizeString(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + input.name = name; + } + if (req.body?.description !== undefined) { + input.description = typeof req.body.description === 'string' ? req.body.description : ''; + } + + try { + const project = await db.updateChatProject(req.user.id, projectId, input); + if (!project) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + res.status(200).json(project); + } catch (error) { + logger.error('[projects] Error updating project', error); + res.status(500).json({ error: 'Error updating project' }); + } +}); + +router.delete('/:projectId', async (req, res) => { + const { projectId } = req.params; + if (!isValidObjectIdString(projectId)) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + + try { + const result = await db.deleteChatProject(req.user.id, projectId); + if (!result.deletedCount) { + return res.status(404).json({ error: PROJECT_NOT_FOUND }); + } + res.status(200).json(result); + } catch (error) { + logger.error('[projects] Error deleting project', error); + res.status(500).json({ error: 'Error deleting project' }); + } +}); + +module.exports = router; diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 4a4e1308841..d2a39612510 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -31,6 +31,7 @@ interface ConversationsProps { isLoading: boolean; isSearchLoading: boolean; isChatsExpanded: boolean; + dateField?: 'updatedAt' | 'createdAt'; setIsChatsExpanded: (expanded: boolean) => void; } @@ -159,6 +160,7 @@ const Conversations: FC = ({ isLoading, isSearchLoading, isChatsExpanded, + dateField = 'updatedAt', setIsChatsExpanded, }) => { const localize = useLocalize(); @@ -189,8 +191,8 @@ const Conversations: FC = ({ ); const groupedConversations = useMemo( - () => groupConversationsByDate(filteredConversations), - [filteredConversations], + () => groupConversationsByDate(filteredConversations, dateField), + [filteredConversations, dateField], ); const flattenedItems = useMemo(() => { diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 46e3be09ebc..923397f623f 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -171,6 +171,7 @@ export default function Conversation({ renameHandler: handleRename, isActiveConvo, conversationId, + chatProjectId: conversation.chatProjectId, isPopoverActive, setIsPopoverActive: handlePopoverOpenChange, isShiftHeld: isActiveConvo ? isShiftHeld : false, diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 25889e0e676..4da01af41ff 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -4,11 +4,21 @@ import { useParams, useNavigate } from 'react-router-dom'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { DropdownPopup, Spinner, useToastContext } from '@librechat/client'; -import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react'; +import { + Ellipsis, + Share2, + CopyPlus, + Archive, + FolderInput, + FolderX, + Pen, + Trash, +} from 'lucide-react'; import type { MouseEvent } from 'react'; import type { TMessage } from 'librechat-data-provider'; import { useDuplicateConversationMutation, + useAssignConversationToProjectMutation, useDeleteConversationMutation, useGetStartupConfig, useArchiveConvoMutation, @@ -17,11 +27,13 @@ import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks'; import { NotificationSeverity } from '~/common'; import { useChatContext } from '~/Providers'; import DeleteButton from './DeleteButton'; +import ProjectButton from './ProjectButton'; import ShareButton from './ShareButton'; import { cn } from '~/utils'; function ConvoOptions({ conversationId, + chatProjectId, title, retainView, renameHandler, @@ -31,6 +43,7 @@ function ConvoOptions({ isShiftHeld = false, }: { conversationId: string | null; + chatProjectId?: string | null; title: string | null; retainView: () => void; renameHandler: (e: MouseEvent) => void; @@ -53,11 +66,14 @@ function ConvoOptions({ const menuId = useId(); const shareButtonRef = useRef(null); const deleteButtonRef = useRef(null); + const projectButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showProjectDialog, setShowProjectDialog] = useState(false); const [announcement, setAnnouncement] = useState(''); const archiveConvoMutation = useArchiveConvoMutation(); + const assignConversationToProject = useAssignConversationToProjectMutation(); const deleteMutation = useDeleteConversationMutation({ onSuccess: () => { @@ -116,6 +132,43 @@ function ConvoOptions({ setShowDeleteDialog(true); }, []); + const projectHandler = useCallback(() => { + setShowProjectDialog(true); + }, []); + + const removeProjectHandler = useCallback(() => { + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + assignConversationToProject.mutate( + { conversationId: convoId, projectId: null }, + { + onSuccess: () => { + setIsPopoverActive(false); + showToast({ + message: localize('com_ui_project_updated'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_project_update_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }, [ + assignConversationToProject, + conversationId, + localize, + setIsPopoverActive, + showToast, + ]); + const handleInstantDelete = useCallback( (e: MouseEvent) => { e.stopPropagation(); @@ -212,6 +265,27 @@ function ConvoOptions({