mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
🗂️ feat: Add Private Chat Projects (#13467)
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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
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
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
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
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* feat: Add private chat projects
* fix: Format project files
* fix: Address project review findings
* fix: Resolve project review follow-ups
* fix: Handle project stats and cache edge cases
* style: align projects UI with sidebar patterns
* fix: resolve projects UI lint issues
* style: Align project menus and composer
* fix: Avoid project placeholder shadowing
* fix: Handle project search and stale ids
* fix: Polish project sidebar behavior
* fix: Preserve new chat stream after creation
* fix: Stabilize project sidebar sections
* fix: Smooth project sidebar organization
* fix: stabilize project chat entry
* fix: keep project workspace outside chat context
* fix: show default model on project workspace
* fix: fallback project workspace model label
* fix: preserve project scope during draft hydration
* fix: include route project in new chat submission
* fix: persist project id in agent chat saves
* fix: refine project sidebar and creation UX
* fix: export chat project method types
* fix: polish project landing context
* fix: refine project navigation affordances
* feat: rework projects UX — coexisting sidebar sections + URL-driven scope
Sidebar
- Replace the chronological/by-project mode toggle with coexisting
Projects + Chats sections (both always visible)
- Remove ProjectConversations (927 lines), the org-mode Header, and types
- Add ProjectsSection: collapsible project rows that unfurl chats inline
(full-size rows), with per-project new chat and an open/rename/delete menu
- Lift the marketplace/favorites shortcuts above the Projects section
Chat scope
- Derive a new chat's project strictly from the URL ?projectId, so the
global New Chat no longer stays stuck in a project after a project chat
Surfaces
- Chat landing: subtle, clickable project chip instead of the floating badge
- Project workspace: modest header, composer-style entry, chats list
- All-projects grid: Claude-style cards with pluralized chat counts
* chore: prune unused i18n keys; fix project chat-count pluralization
* fix: project new-chat keeps model spec; sidebar header + row polish
- newConversation: ignore a chatProjectId-only template when deciding to
apply the default model spec, so starting a chat in a project no longer
strips the conversation `spec`
- useSelectMention: the Model Selector and @ command now retain the active
project across endpoint/spec/preset switches; other new-chat paths still
clear it
- Chats header now matches the Projects header (inline chevron + a new-chat
icon button) and starts a non-project chat
- Project rows: use the new-chat icon for the per-project add button, render
at text-sm to match the chat list, and align the row actions + hover color
with conversation rows
* fix: read project scope from router params; align sidebar header icons
- useSelectMention now reads the active project from React Router's search
params instead of window.location, which can drift out of sync because
new-chat params are written to the URL via raw history.pushState; the
Model Selector and @ command now reliably keep the project on switch
- Move the Chats section header out of the virtualized list so it renders
in the same context as the Projects header and isn't shifted by the
list scrollbar
- Inset header action icons (pr-2) so Projects/Chats header icons line up
with the project-row and conversation-row trailing actions
- Extract getRouteChatProjectId into utils for the submit path
* fix: preserve chatProjectId through the new-chat template reduction
The param-endpoint guard in newConversation reduced a new chat's template to
{ endpoint } only, dropping the chatProjectId injected by the Model Selector /
@ switch — so switching models cleared the project scope. Keep chatProjectId
in the reduced template.
* style: align chat-history panel top padding; improve projects page contrast
- Add pt-2 to the chat-history panel so its top spacing matches the other
side panels (agent builder, skills, files, etc.)
- Projects grid + workspace now use the darkest surface for the page
(surface-primary) with cards, inputs, and the composer one step lighter
(surface-secondary) and tertiary on hover, so cards read as elevated
rather than darker than the background
* feat: interactive project landing chip + gallery icon for all-projects
- All-projects sidebar button uses the gallery-vertical-end icon
- The project landing chip is now interactive: click it to switch projects
via a searchable combobox (ControlCombobox), or the trailing × to drop the
project scope. Both update the draft conversation and the ?projectId search
param in place, so the typed message and selected model are preserved
* test: fix Conversations unit test for refactored sidebar; add projects e2e
- Update Conversations.test.tsx mocks for the inline Chats header
(useNewConvo, useQueryClient, conversation atom, NewChatIcon, TooltipAnchor),
drop the removed chatsHeaderControls prop, and remove the mock for the
deleted ../Header module — fixes the failing frontend Jest job
- Add e2e/specs/mock/projects.spec.ts covering project creation, the
project-scoped new-chat landing + interactive chip (switch/remove), and
listing projects on /projects
- Give the landing chip combobox a stable selectId for reliable targeting
* fix: refresh project stats after project-chat activity; stabilize e2e
- useEventHandlers: when a project chat is created/updated, invalidate the
live [projects] query (gated on chatProjectId) instead of the now-unused
projectConversations key, so the sidebar + all-projects stats refresh
after a streamed reply (addresses a Codex finding)
- projects e2e: assert the reliable project-landing behavior (chip, scoped
composer, accepted send) rather than the /c/:id transition, which the
mock LLM harness doesn't complete
* test: verify a project chat saves and is filed under its project (e2e)
- Switch to a mock endpoint before sending so the message streams without a
real API key (the default model failed with "No key found", so no chat was
saved and the page never left /c/new); this also asserts the project chip
survives the model switch
- Restore the reply + /c/:id transition assertions and add a check that the
chat is listed under the expanded project in the sidebar
- Add data-testid="project-chats-<id>" to the inline project chat list
* fix: address Codex review findings (project scope edge cases)
- useSelectMention: fall back to the conversation's chatProjectId when the
URL has no projectId, so switching model/spec inside an existing project
chat (/c/:id) keeps the project assignment
- Conversations: include chatProjectId in the MemoizedConvo comparator so a
sidebar row's project menu doesn't stay stale after a reassignment
- useDeleteProjectMutation: clear the active conversation's chatProjectId
when its project is deleted (mirrors the assignment mutation); drop the
now-dead projectConversations invalidation
- useQueryParams: carry the project into the new conversation when applying
URL settings, so /c/new?projectId=...&<settings> stays scoped
* fix: project stats pagination + archived-chat edge cases (data-schemas)
- listChatProjects: include the null lastConversationAt bucket in the desc
cursor so empty projects paginate (a $lt:<date> predicate excluded nulls,
hiding chat-less projects from "Load more")
- saveConvo: recompute project stats instead of the incremental fast path
when the saved conversation is itself archived/temporary/expired, so a
project's lastConversationAt/Id no longer points at a hidden chat
* test: cover chat-less project pagination across the dated→null boundary
* fix: validate project ownership in bulkSaveConvos
Bulk paths (import/duplicate/fork) persisted whatever chatProjectId the
payload carried; an id that does not belong to the user created an orphan
assignment hidden from both the project and the unassigned sidebar. Validate
ownership like saveConvo and strip un-owned project ids before persisting,
refreshing stats only for owned projects.
* fix(projects): preserve chatProjectId on continuation, basename-safe delete redirect, project-detail invalidation
* fix(projects): navigate project workspace chats via useNavigateToConvo to avoid stale conversation state
* fix(projects): include projectConversations cache when resolving deleted chat's project for detail invalidation
* fix(projects): refresh both projects when a save or bulk write moves a chat between them
* style(projects): use Folders icon for the sidebar Projects header
* fix(projects): require id on ProjectUser so ProjectRequest extends Express Request cleanly
* style(projects): taller project chip with hover-revealed remove button, upward combobox; sort en translations
* style(projects): show endpoint/agent icon for project workspace chat rows
This commit is contained in:
parent
86fe79c37d
commit
baa23a8e24
70 changed files with 4082 additions and 107 deletions
|
|
@ -209,6 +209,7 @@ class AgentClient extends BaseClient {
|
|||
{
|
||||
spec: this.options.spec,
|
||||
iconURL: this.options.iconURL,
|
||||
chatProjectId: this.options.chatProjectId,
|
||||
endpoint: this.options.endpoint,
|
||||
agent_id: this.options.agent.id,
|
||||
modelLabel: this.options.modelLabel,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ const assistantClients = {
|
|||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
const isValidProjectFilter = (projectId) =>
|
||||
!projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
|
|
@ -38,6 +41,13 @@ 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;
|
||||
|
||||
if (!isValidProjectFilter(projectId)) {
|
||||
return res.status(400).json({ error: 'projectId must be a valid project id or unassigned' });
|
||||
}
|
||||
|
||||
let tags;
|
||||
if (req.query.tags) {
|
||||
|
|
@ -53,6 +63,7 @@ router.get('/', async (req, res) => {
|
|||
search,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
projectId,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
25
api/server/routes/projects.js
Normal file
25
api/server/routes/projects.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const express = require('express');
|
||||
const { createProjectHandlers } = require('@librechat/api');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
const handlers = createProjectHandlers({
|
||||
listChatProjects: db.listChatProjects,
|
||||
createChatProject: db.createChatProject,
|
||||
getChatProject: db.getChatProject,
|
||||
updateChatProject: db.updateChatProject,
|
||||
deleteChatProject: db.deleteChatProject,
|
||||
assignConversationToProject: db.assignConversationToProject,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/', handlers.listProjects);
|
||||
router.post('/', handlers.createProject);
|
||||
router.put('/conversations/:conversationId', handlers.assignConversationToProject);
|
||||
router.get('/:projectId', handlers.getProject);
|
||||
router.patch('/:projectId', handlers.updateProject);
|
||||
router.delete('/:projectId', handlers.deleteProject);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -7,7 +7,7 @@ const db = require('~/models');
|
|||
const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
|
||||
const { spec, iconURL, agent_id, chatProjectId, ...model_parameters } = parsedBody;
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
|
|
@ -28,6 +28,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
|||
endpoint,
|
||||
agent_id,
|
||||
endpointType,
|
||||
chatProjectId,
|
||||
model_parameters,
|
||||
agent: agentPromise,
|
||||
addedConvo,
|
||||
|
|
|
|||
|
|
@ -857,6 +857,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
agent: primaryConfig,
|
||||
spec: endpointOption.spec,
|
||||
iconURL: endpointOption.iconURL,
|
||||
chatProjectId: endpointOption.chatProjectId,
|
||||
attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments,
|
||||
agentContextAttachmentsByAgentId,
|
||||
endpointType: endpointOption.endpointType,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ import { useForm } from 'react-hook-form';
|
|||
import { Spinner } from '@librechat/client';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants, buildTree } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TChatProject, TMessage } from 'librechat-data-provider';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
|
||||
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
|
||||
import {
|
||||
useAddedResponse,
|
||||
useResumeOnLoad,
|
||||
useAdaptiveSSE,
|
||||
useChatHelpers,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import ConversationStarters from './Input/ConversationStarters';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import Presentation from './Presentation';
|
||||
import ProjectLandingChip from './ProjectLandingChip';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
|
|
@ -29,8 +36,9 @@ function LoadingSpinner() {
|
|||
);
|
||||
}
|
||||
|
||||
function ChatView({ index = 0 }: { index?: number }) {
|
||||
function ChatView({ index = 0, project }: { index?: number; project?: TChatProject }) {
|
||||
const { conversationId } = useParams();
|
||||
const localize = useLocalize();
|
||||
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
||||
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
|
||||
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
||||
|
|
@ -70,6 +78,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
(!messagesTree || messagesTree.length === 0) &&
|
||||
(conversationId === Constants.NEW_CONVO || !conversationId);
|
||||
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
|
||||
const isProjectLandingPage = isLandingPage && project != null;
|
||||
|
||||
if (isLoading && conversationId !== Constants.NEW_CONVO) {
|
||||
content = <LoadingSpinner />;
|
||||
|
|
@ -81,6 +90,11 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
|
||||
}
|
||||
|
||||
const chatFormPlaceholder =
|
||||
isProjectLandingPage && project
|
||||
? localize('com_ui_new_chat_in_project', { name: project.name })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ChatFormProvider {...methods}>
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
|
|
@ -104,7 +118,8 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
|
||||
)}
|
||||
>
|
||||
<ChatForm index={index} />
|
||||
{isProjectLandingPage && project && <ProjectLandingChip project={project} />}
|
||||
<ChatForm index={index} placeholder={chatFormPlaceholder} />
|
||||
{isLandingPage ? <ConversationStarters /> : <Footer />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import store from '~/store';
|
|||
|
||||
interface ChatFormProps {
|
||||
index: number;
|
||||
placeholder?: string;
|
||||
/** From ChatContext — individual values so memo can compare them */
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
|
|
@ -55,6 +56,7 @@ interface ChatFormProps {
|
|||
|
||||
const ChatForm = memo(function ChatForm({
|
||||
index,
|
||||
placeholder,
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
|
|
@ -177,6 +179,7 @@ const ChatForm = memo(function ChatForm({
|
|||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled: disableInputs,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
useQueryParams({ textAreaRef });
|
||||
|
|
@ -413,7 +416,7 @@ ChatForm.displayName = 'ChatForm';
|
|||
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
|
||||
* streaming chunk — it only re-renders when the specific values it uses change.
|
||||
*/
|
||||
function ChatFormWrapper({ index = 0 }: { index?: number }) {
|
||||
function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
|
|
@ -465,6 +468,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) {
|
|||
return (
|
||||
<ChatForm
|
||||
index={index}
|
||||
placeholder={placeholder}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
conversation={stableConversation}
|
||||
|
|
|
|||
89
client/src/components/Chat/ProjectLandingChip.tsx
Normal file
89
client/src/components/Chat/ProjectLandingChip.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Folder, X } from 'lucide-react';
|
||||
import { ControlCombobox, TooltipAnchor } from '@librechat/client';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Subtle project indicator shown on a project-scoped new-chat landing.
|
||||
* Clicking the chip switches the project (searchable combobox); the trailing
|
||||
* `×` removes the project scope. Both update the conversation draft and the
|
||||
* `?projectId` search param in place, so the typed message and model are kept.
|
||||
*/
|
||||
export default function ProjectLandingChip({ project }: { project: TChatProject }) {
|
||||
const localize = useLocalize();
|
||||
const { conversation, setConversation } = useChatContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { data } = useProjectsInfiniteQuery(
|
||||
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 100 },
|
||||
{ staleTime: 30000 },
|
||||
);
|
||||
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
|
||||
const items = useMemo<OptionWithIcon[]>(
|
||||
() =>
|
||||
projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item._id,
|
||||
icon: <Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />,
|
||||
})),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const applyProject = useCallback(
|
||||
(projectId: string | null) => {
|
||||
if (conversation) {
|
||||
setConversation({ ...conversation, chatProjectId: projectId });
|
||||
}
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (projectId) {
|
||||
nextParams.set('projectId', projectId);
|
||||
} else {
|
||||
nextParams.delete('projectId');
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
},
|
||||
[conversation, setConversation, searchParams, setSearchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group mb-2.5 flex items-center gap-1 px-1">
|
||||
<ControlCombobox
|
||||
selectId="project-landing-select"
|
||||
selectedValue={project._id}
|
||||
displayValue={project.name}
|
||||
items={items}
|
||||
setValue={(value) => {
|
||||
if (value) {
|
||||
applyProject(value);
|
||||
}
|
||||
}}
|
||||
SelectIcon={<Folder className="h-3.5 w-3.5 text-text-secondary" aria-hidden="true" />}
|
||||
ariaLabel={localize('com_ui_change_project')}
|
||||
searchPlaceholder={localize('com_ui_search_projects')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
placement="top"
|
||||
containerClassName="w-auto px-0"
|
||||
className="h-8 w-auto min-w-[11rem] gap-1.5 rounded-full px-2.5 text-sm font-medium text-text-secondary hover:text-text-primary"
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_remove_from_project')}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_remove_from_project')}
|
||||
onClick={() => applyProject(null)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-secondary opacity-0 outline-none transition-all hover:bg-surface-hover hover:text-text-primary focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,13 +2,21 @@ import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
|
|||
import throttle from 'lodash/throttle';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Spinner, useMediaQuery } from '@librechat/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { Spinner, TooltipAnchor, NewChatIcon, useMediaQuery } from '@librechat/client';
|
||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
|
||||
import {
|
||||
useLocalize,
|
||||
TranslationKeys,
|
||||
useFavorites,
|
||||
useShowMarketplace,
|
||||
useNewConvo,
|
||||
} from '~/hooks';
|
||||
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
|
||||
import { useActiveJobs } from '~/data-provider';
|
||||
import { groupConversationsByDate, cn } from '~/utils';
|
||||
import { groupConversationsByDate, clearMessagesCache, cn } from '~/utils';
|
||||
import Convo from './Convo';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -32,6 +40,7 @@ interface ConversationsProps {
|
|||
isSearchLoading: boolean;
|
||||
isChatsExpanded: boolean;
|
||||
setIsChatsExpanded: (expanded: boolean) => void;
|
||||
showFavorites?: boolean;
|
||||
}
|
||||
|
||||
interface MeasuredRowProps {
|
||||
|
|
@ -76,20 +85,53 @@ interface ChatsHeaderProps {
|
|||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const headerIconButtonClassName =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white';
|
||||
|
||||
/** Collapsible header for the Chats section */
|
||||
const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle }) => {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
}, [conversation?.conversationId, newConversation, queryClient]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="group flex w-full items-center justify-between rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
type="button"
|
||||
>
|
||||
<span className="select-none">{localize('com_ui_chats')}</span>
|
||||
<ChevronDown
|
||||
className={cn('h-3 w-3 transition-transform duration-200', isExpanded ? 'rotate-180' : '')}
|
||||
<div className="flex h-8 w-full items-center gap-0.5 pr-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="select-none truncate">{localize('com_ui_chats')}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 transition-transform duration-200',
|
||||
isExpanded ? '' : '-rotate-90',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className={headerIconButtonClassName}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -114,7 +156,6 @@ DateLabel.displayName = 'DateLabel';
|
|||
|
||||
type FlattenedItem =
|
||||
| { type: 'favorites' }
|
||||
| { type: 'chats-header' }
|
||||
| { type: 'header'; groupName: string }
|
||||
| { type: 'convo'; convo: TConversation }
|
||||
| { type: 'loading' };
|
||||
|
|
@ -145,6 +186,7 @@ const MemoizedConvo = memo(
|
|||
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
|
||||
prevProps.conversation.title === nextProps.conversation.title &&
|
||||
prevProps.conversation.endpoint === nextProps.conversation.endpoint &&
|
||||
prevProps.conversation.chatProjectId === nextProps.conversation.chatProjectId &&
|
||||
prevProps.isGenerating === nextProps.isGenerating
|
||||
);
|
||||
},
|
||||
|
|
@ -160,6 +202,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
isSearchLoading,
|
||||
isChatsExpanded,
|
||||
setIsChatsExpanded,
|
||||
showFavorites = true,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const search = useRecoilValue(store.search);
|
||||
|
|
@ -179,7 +222,9 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
// Determine if FavoritesList will render content
|
||||
const shouldShowFavorites =
|
||||
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
|
||||
showFavorites &&
|
||||
!search.query &&
|
||||
(isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
|
||||
|
||||
favoritesContentKeyRef.current = `${favorites.length}-${showAgentMarketplace ? 1 : 0}-${isFavoritesLoading ? 1 : 0}`;
|
||||
|
||||
|
|
@ -199,7 +244,6 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
if (shouldShowFavorites) {
|
||||
items.push({ type: 'favorites' });
|
||||
}
|
||||
items.push({ type: 'chats-header' });
|
||||
|
||||
if (isChatsExpanded) {
|
||||
groupedConversations.forEach(([groupName, convos]) => {
|
||||
|
|
@ -232,9 +276,6 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
if (item.type === 'favorites') {
|
||||
return `favorites-${favoritesContentKeyRef.current}`;
|
||||
}
|
||||
if (item.type === 'chats-header') {
|
||||
return 'chats-header';
|
||||
}
|
||||
if (item.type === 'header') {
|
||||
return `header-${item.groupName}`;
|
||||
}
|
||||
|
|
@ -297,22 +338,9 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (item.type === 'chats-header') {
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<ChatsHeader
|
||||
isExpanded={isChatsExpanded}
|
||||
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
|
||||
/>
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'header') {
|
||||
// First date header index depends on whether favorites row is included
|
||||
// With favorites: [favorites, chats-header, first-header] → index 2
|
||||
// Without favorites: [chats-header, first-header] → index 1
|
||||
const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
|
||||
// First date header index depends on whether the favorites row is included
|
||||
const firstHeaderIndex = shouldShowFavorites ? 1 : 0;
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
|
||||
|
|
@ -336,17 +364,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
cache,
|
||||
flattenedItems,
|
||||
moveToTop,
|
||||
toggleNav,
|
||||
isSmallScreen,
|
||||
isChatsExpanded,
|
||||
setIsChatsExpanded,
|
||||
shouldShowFavorites,
|
||||
activeJobIds,
|
||||
],
|
||||
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, shouldShowFavorites, activeJobIds],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
|
|
@ -370,6 +388,12 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary">
|
||||
<div className="px-3">
|
||||
<ChatsHeader
|
||||
isExpanded={isChatsExpanded}
|
||||
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
|
||||
/>
|
||||
</div>
|
||||
{isSearchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export default function Conversation({
|
|||
renameHandler: handleRename,
|
||||
isActiveConvo,
|
||||
conversationId,
|
||||
chatProjectId: conversation.chatProjectId,
|
||||
isPopoverActive,
|
||||
setIsPopoverActive: handlePopoverOpenChange,
|
||||
isShiftHeld: isActiveConvo ? isShiftHeld : false,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,22 @@ import * as Ariakit from '@ariakit/react';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
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 { QueryKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import {
|
||||
useDuplicateConversationMutation,
|
||||
useAssignConversationToProjectMutation,
|
||||
useDeleteConversationMutation,
|
||||
useGetStartupConfig,
|
||||
useArchiveConvoMutation,
|
||||
|
|
@ -17,11 +27,13 @@ import { useHasAccess, useLocalize, useNavigateToConvo, useNewConvo } from '~/ho
|
|||
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,8 +66,10 @@ function ConvoOptions({
|
|||
const menuId = useId();
|
||||
const shareButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const projectButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showProjectDialog, setShowProjectDialog] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
const canCreateSharedLinks = useHasAccess({
|
||||
|
|
@ -63,6 +78,7 @@ function ConvoOptions({
|
|||
});
|
||||
|
||||
const archiveConvoMutation = useArchiveConvoMutation();
|
||||
const assignConversationToProject = useAssignConversationToProjectMutation();
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: () => {
|
||||
|
|
@ -121,6 +137,37 @@ 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();
|
||||
|
|
@ -217,6 +264,27 @@ function ConvoOptions({
|
|||
<CopyPlus className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_change_project'),
|
||||
onClick: projectHandler,
|
||||
icon: <FolderInput className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
|
||||
ariaHasPopup: 'dialog' as const,
|
||||
ariaControls: 'project-conversation-dialog',
|
||||
hideOnClick: false,
|
||||
ref: projectButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_remove_from_project'),
|
||||
onClick: removeProjectHandler,
|
||||
show: Boolean(chatProjectId),
|
||||
hideOnClick: false,
|
||||
icon: assignConversationToProject.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<FolderX className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_archive'),
|
||||
onClick: handleArchiveClick,
|
||||
|
|
@ -250,6 +318,10 @@ function ConvoOptions({
|
|||
handleArchiveClick,
|
||||
canCreateSharedLinks,
|
||||
handleDuplicateClick,
|
||||
projectHandler,
|
||||
removeProjectHandler,
|
||||
chatProjectId,
|
||||
assignConversationToProject.isLoading,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -348,6 +420,16 @@ function ConvoOptions({
|
|||
setShowDeleteDialog={setShowDeleteDialog}
|
||||
/>
|
||||
)}
|
||||
{showProjectDialog && (
|
||||
<ProjectButton
|
||||
conversationId={conversationId ?? ''}
|
||||
chatProjectId={chatProjectId}
|
||||
setMenuOpen={setIsPopoverActive}
|
||||
triggerRef={projectButtonRef}
|
||||
showProjectDialog={showProjectDialog}
|
||||
setShowProjectDialog={setShowProjectDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -356,6 +438,7 @@ export default memo(ConvoOptions, (prevProps, nextProps) => {
|
|||
return (
|
||||
prevProps.conversationId === nextProps.conversationId &&
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.chatProjectId === nextProps.chatProjectId &&
|
||||
prevProps.isPopoverActive === nextProps.isPopoverActive &&
|
||||
prevProps.isActiveConvo === nextProps.isActiveConvo &&
|
||||
prevProps.isShiftHeld === nextProps.isShiftHeld
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { useEffect, useMemo, useState, type RefObject } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import { useAssignConversationToProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
||||
type ProjectButtonProps = {
|
||||
conversationId: string;
|
||||
chatProjectId?: string | null;
|
||||
showProjectDialog?: boolean;
|
||||
setShowProjectDialog?: (value: boolean) => void;
|
||||
triggerRef?: RefObject<HTMLButtonElement>;
|
||||
setMenuOpen?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function ProjectConversationDialog({
|
||||
conversationId,
|
||||
chatProjectId,
|
||||
setMenuOpen,
|
||||
setShowProjectDialog,
|
||||
}: {
|
||||
conversationId: string;
|
||||
chatProjectId?: string | null;
|
||||
setMenuOpen?: (open: boolean) => void;
|
||||
setShowProjectDialog: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(chatProjectId ?? '');
|
||||
const assignConversation = useAssignConversationToProjectMutation();
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useProjectsInfiniteQuery({
|
||||
sortBy: 'name',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
const projects = useMemo<TChatProject[]>(
|
||||
() => data?.pages.flatMap((page) => page.projects) ?? [],
|
||||
[data?.pages],
|
||||
);
|
||||
const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProjectId(chatProjectId ?? '');
|
||||
}, [chatProjectId]);
|
||||
|
||||
const saveProject = () => {
|
||||
assignConversation.mutate(
|
||||
{
|
||||
conversationId,
|
||||
projectId: selectedProjectId || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowProjectDialog(false);
|
||||
setMenuOpen?.(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,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialogContent
|
||||
id="project-conversation-dialog"
|
||||
className="w-11/12 max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_change_project')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<label className="flex flex-col gap-2 text-sm text-text-primary">
|
||||
{localize('com_ui_select_project')}
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||
className="h-10 rounded-lg border border-border-light bg-surface-primary px-3 text-sm outline-none focus:ring-2 focus:ring-ring-primary"
|
||||
>
|
||||
<option value="">{localize('com_ui_unassigned')}</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project._id} value={project._id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{hasNextPage && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-sm text-text-secondary hover:text-text-primary"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button aria-label="cancel" variant="outline">
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button onClick={saveProject} disabled={assignConversation.isLoading}>
|
||||
{assignConversation.isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectButton({
|
||||
conversationId,
|
||||
chatProjectId,
|
||||
setMenuOpen,
|
||||
showProjectDialog,
|
||||
setShowProjectDialog,
|
||||
triggerRef,
|
||||
}: ProjectButtonProps) {
|
||||
if (showProjectDialog === undefined || setShowProjectDialog === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={showProjectDialog} onOpenChange={setShowProjectDialog} triggerRef={triggerRef}>
|
||||
<ProjectConversationDialog
|
||||
conversationId={conversationId}
|
||||
chatProjectId={chatProjectId}
|
||||
setMenuOpen={setMenuOpen}
|
||||
setShowProjectDialog={setShowProjectDialog}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './DeleteButton';
|
||||
export { default as ProjectButton } from './ProjectButton';
|
||||
export { default as ShareButton } from './ShareButton';
|
||||
export { default as SharedLinkButton } from './SharedLinkButton';
|
||||
export { default as ConvoOptions } from './ConvoOptions';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './DeleteButton';
|
||||
export { default as ProjectButton } from './ProjectButton';
|
||||
export { default as ShareButton } from './ShareButton';
|
||||
export { default as SharedLinkButton } from './SharedLinkButton';
|
||||
export { default as ConvoOptions } from './ConvoOptions';
|
||||
|
|
|
|||
516
client/src/components/Conversations/ProjectsSection.tsx
Normal file
516
client/src/components/Conversations/ProjectsSection.tsx
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Ellipsis,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Folders,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { TChatProject, TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
TooltipAnchor,
|
||||
DropdownPopup,
|
||||
NewChatIcon,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import {
|
||||
useProjectsInfiniteQuery,
|
||||
useConversationsInfiniteQuery,
|
||||
useUpdateProjectMutation,
|
||||
useDeleteProjectMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
|
||||
import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
|
||||
import { clearMessagesCache, cn } from '~/utils';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import Convo from './Convo';
|
||||
import store from '~/store';
|
||||
|
||||
const INLINE_CHAT_LIMIT = 8;
|
||||
|
||||
const iconButtonClassName =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white';
|
||||
|
||||
function ProjectRenameDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TChatProject;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const formId = useId();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [name, setName] = useState(project.name);
|
||||
const updateProject = useUpdateProjectMutation();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setName(project.name);
|
||||
const frameId = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [open, project.name]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || updateProject.isLoading) {
|
||||
return;
|
||||
}
|
||||
updateProject.mutate(
|
||||
{ projectId: project._id, name: trimmed },
|
||||
{
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_project_rename_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_rename_project')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<form id={formId} onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
aria-label={localize('com_ui_project_name')}
|
||||
className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button aria-label="cancel" variant="outline">
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="submit"
|
||||
disabled={!name.trim() || updateProject.isLoading}
|
||||
>
|
||||
{updateProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TChatProject;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const deleteProject = useDeleteProjectMutation();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteProject.mutate(project._id, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
if (location.pathname === `/projects/${project._id}`) {
|
||||
navigate('/projects');
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_project_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_delete_project')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_ui_delete_project_confirm', { name: project.name })}
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button aria-label="cancel" variant="outline">
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteProject.isLoading}>
|
||||
{deleteProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function ProjectChatsInline({
|
||||
projectId,
|
||||
toggleNav,
|
||||
onShowAll,
|
||||
}: {
|
||||
projectId: string;
|
||||
toggleNav: () => void;
|
||||
onShowAll: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { data, isLoading } = useConversationsInfiniteQuery(
|
||||
{ projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
|
||||
{ staleTime: 30000, cacheTime: 300000 },
|
||||
);
|
||||
|
||||
const conversations = useMemo<TConversation[]>(
|
||||
() =>
|
||||
(data?.pages.flatMap((page) => page.conversations) ?? []).filter(Boolean) as TConversation[],
|
||||
[data?.pages],
|
||||
);
|
||||
const hasMore =
|
||||
conversations.length > INLINE_CHAT_LIMIT ||
|
||||
(data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
|
||||
const visible = conversations.slice(0, INLINE_CHAT_LIMIT);
|
||||
|
||||
if (isLoading && conversations.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-start py-1.5 pl-2">
|
||||
<Spinner className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="py-1.5 pl-2 text-xs text-text-secondary">
|
||||
{localize('com_ui_no_project_chats')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={`project-chats-${projectId}`}>
|
||||
{visible.map((convo) => (
|
||||
<Convo
|
||||
key={convo.conversationId}
|
||||
conversation={convo}
|
||||
retainView={noop}
|
||||
toggleNav={toggleNav}
|
||||
isGenerating={false}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowAll}
|
||||
className="ml-1 mt-0.5 rounded-md px-2 py-1 text-xs font-medium text-text-secondary outline-none transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
>
|
||||
{localize('com_ui_show_all')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
project,
|
||||
toggleNav,
|
||||
defaultExpanded,
|
||||
}: {
|
||||
project: TChatProject;
|
||||
toggleNav: () => void;
|
||||
defaultExpanded: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const menuId = useId();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const openProject = useCallback(() => {
|
||||
navigate(`/projects/${project._id}`);
|
||||
toggleNav();
|
||||
}, [navigate, project._id, toggleNav]);
|
||||
|
||||
const startChat = useCallback(() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation({ template: { chatProjectId: project._id } });
|
||||
toggleNav();
|
||||
}, [conversation?.conversationId, newConversation, project._id, queryClient, toggleNav]);
|
||||
|
||||
const menuItems = useMemo<MenuItemProps[]>(
|
||||
() => [
|
||||
{
|
||||
id: `${menuId}-open`,
|
||||
label: localize('com_ui_open_project'),
|
||||
icon: <Folder className="size-4 text-text-secondary" aria-hidden="true" />,
|
||||
onClick: openProject,
|
||||
},
|
||||
{
|
||||
id: `${menuId}-rename`,
|
||||
label: localize('com_ui_rename'),
|
||||
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
|
||||
onClick: () => setIsRenameOpen(true),
|
||||
},
|
||||
{
|
||||
id: `${menuId}-delete`,
|
||||
label: localize('com_ui_delete'),
|
||||
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
|
||||
onClick: () => setIsDeleteOpen(true),
|
||||
},
|
||||
],
|
||||
[localize, menuId, openProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="list-none">
|
||||
<div className="group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary transition-colors hover:bg-surface-active-alt">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={project.name}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg py-1.5 pl-1.5 pr-14 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-text-secondary transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<span className="truncate">{project.name}</span>
|
||||
</button>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-surface-active-alt opacity-0 transition-opacity group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100 has-[[data-state=open]]:opacity-100">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat_in_project', { name: project.name })}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
|
||||
className={iconButtonClassName}
|
||||
onClick={startChat}
|
||||
>
|
||||
<NewChatIcon className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
menuId={menuId}
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
className="z-[125] min-w-44"
|
||||
iconClassName="mr-2 text-text-secondary"
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={localize('com_ui_more_options')}
|
||||
className={cn(
|
||||
iconButtonClassName,
|
||||
isMenuOpen && 'bg-surface-active-alt text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" aria-hidden="true" />
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={menuItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<ProjectChatsInline projectId={project._id} toggleNav={toggleNav} onShowAll={openProject} />
|
||||
)}
|
||||
<ProjectRenameDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
|
||||
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectsSectionProps {
|
||||
toggleNav: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const [isExpanded, setIsExpanded] = useLocalStorage('projectsSectionExpanded', true);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const activeProjectId = conversation?.chatProjectId ?? null;
|
||||
|
||||
const { data, isLoading } = useProjectsInfiniteQuery(
|
||||
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
|
||||
{ enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
|
||||
);
|
||||
|
||||
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
|
||||
const hasMore = (data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
|
||||
|
||||
const openProjects = useCallback(() => {
|
||||
navigate('/projects');
|
||||
toggleNav();
|
||||
}, [navigate, toggleNav]);
|
||||
|
||||
const renderProjectsBody = () => {
|
||||
if (isLoading && projects.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-start py-2 pl-2">
|
||||
<Spinner className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{localize('com_ui_new_project')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="m-0 list-none p-0">
|
||||
{projects.map((project) => (
|
||||
<ProjectItem
|
||||
key={project._id}
|
||||
project={project}
|
||||
toggleNav={toggleNav}
|
||||
defaultExpanded={project._id === activeProjectId}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<li className="list-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openProjects}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-medium text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
>
|
||||
{localize('com_ui_all_projects')}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3 text-sm">
|
||||
<div className="flex h-8 w-full items-center gap-0.5 pr-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="select-none truncate">{localize('com_ui_projects')}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 transition-transform duration-200',
|
||||
isExpanded ? '' : '-rotate-90',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_all_projects')}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_all_projects')}
|
||||
className={iconButtonClassName}
|
||||
onClick={openProjects}
|
||||
>
|
||||
<Folders className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_project')}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_new_project')}
|
||||
className={iconButtonClassName}
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && <div className="max-h-[42vh] overflow-y-auto">{renderProjectsBody()}</div>}
|
||||
|
||||
<ProjectCreateDialog
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
onCreated={(project) => {
|
||||
navigate(`/projects/${project._id}`);
|
||||
toggleNav();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectsSection.displayName = 'ProjectsSection';
|
||||
|
||||
export default memo(ProjectsSection);
|
||||
|
|
@ -48,11 +48,15 @@ jest.mock('react-virtualized', () => {
|
|||
});
|
||||
|
||||
jest.mock('~/store', () => {
|
||||
const { atom } = jest.requireActual('recoil');
|
||||
const { atom, atomFamily } = jest.requireActual('recoil');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
search: atom({ key: 'test-conversations-search', default: { query: '' } }),
|
||||
conversationByIndex: atomFamily({
|
||||
key: 'test-conversations-convo-by-index',
|
||||
default: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -70,12 +74,19 @@ jest.mock('~/hooks', () => ({
|
|||
useFavorites: () => mockFavoritesState,
|
||||
useLocalize: () => (key: string) => key,
|
||||
useShowMarketplace: () => mockShowMarketplace,
|
||||
useNewConvo: () => ({ newConversation: jest.fn() }),
|
||||
TranslationKeys: {},
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
useMediaQuery: () => false,
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
NewChatIcon: () => <svg data-testid="new-chat-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
|
|
@ -84,6 +95,7 @@ jest.mock('~/data-provider', () => ({
|
|||
|
||||
jest.mock('~/utils', () => ({
|
||||
groupConversationsByDate: () => [],
|
||||
clearMessagesCache: jest.fn(),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
|
|
|
|||
249
client/src/components/Projects/ProjectChatList.tsx
Normal file
249
client/src/components/Projects/ProjectChatList.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import { groupConversationsByDate, cn } from '~/utils';
|
||||
import { DateLabel } from '~/components/Conversations/Conversations';
|
||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
|
||||
type ChatSortField = 'updatedAt' | 'createdAt';
|
||||
|
||||
type FlattenedItem =
|
||||
| { type: 'date'; groupName: string }
|
||||
| { type: 'convo'; convo: TConversation }
|
||||
| { type: 'loading' }
|
||||
| { type: 'empty' };
|
||||
|
||||
interface ProjectChatListProps {
|
||||
conversations: TConversation[];
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
sortBy: ChatSortField;
|
||||
emptyLabel: string;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
interface MeasuredRowProps {
|
||||
cache: CellMeasurerCache;
|
||||
rowKey: string;
|
||||
parent: MeasuredCellParent;
|
||||
index: number;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MeasuredRow: FC<MeasuredRowProps> = memo(
|
||||
({ cache, rowKey, parent, index, style, children }) => (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={rowKey} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
),
|
||||
);
|
||||
|
||||
MeasuredRow.displayName = 'ProjectWorkspaceMeasuredRow';
|
||||
|
||||
const LoadingRow = memo(() => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-text-secondary">
|
||||
<Spinner className="text-text-primary" />
|
||||
<span>{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LoadingRow.displayName = 'ProjectWorkspaceLoadingRow';
|
||||
|
||||
const ConversationRow = memo(({ conversation }: { conversation: TConversation }) => {
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const localize = useLocalize();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const title = conversation.title || localize('com_ui_untitled');
|
||||
const updatedAt = conversation.updatedAt || conversation.createdAt;
|
||||
const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 border-b border-border-light py-3 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary"
|
||||
onClick={() => navigateToConvo(conversation)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<EndpointIcon
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig ?? {}}
|
||||
size={24}
|
||||
context="menu-item"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
|
||||
<span className="block truncate text-xs text-text-secondary">{formattedDate}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
ConversationRow.displayName = 'ProjectWorkspaceConversationRow';
|
||||
|
||||
const ProjectChatList = ({
|
||||
conversations,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
sortBy,
|
||||
emptyLabel,
|
||||
loadMore,
|
||||
}: ProjectChatListProps) => {
|
||||
const flattenedItems = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return [{ type: 'loading' as const }];
|
||||
}
|
||||
if (!conversations.length) {
|
||||
return [{ type: 'empty' as const }];
|
||||
}
|
||||
|
||||
const items: FlattenedItem[] = [];
|
||||
groupConversationsByDate(conversations, sortBy).forEach(([groupName, convos]) => {
|
||||
items.push({ type: 'date', groupName });
|
||||
convos.forEach((convo) => items.push({ type: 'convo', convo }));
|
||||
});
|
||||
if (isFetchingNextPage) {
|
||||
items.push({ type: 'loading' });
|
||||
}
|
||||
return items;
|
||||
}, [conversations, isFetchingNextPage, isLoading, sortBy]);
|
||||
|
||||
const flattenedItemsRef = useRef(flattenedItems);
|
||||
flattenedItemsRef.current = flattenedItems;
|
||||
|
||||
const cache = useMemo(
|
||||
() =>
|
||||
new CellMeasurerCache({
|
||||
fixedWidth: true,
|
||||
defaultHeight: 52,
|
||||
keyMapper: (index) => {
|
||||
const item = flattenedItemsRef.current[index];
|
||||
if (!item) {
|
||||
return `project-workspace-unknown-${index}`;
|
||||
}
|
||||
if (item.type === 'date') {
|
||||
return `project-workspace-date-${item.groupName}`;
|
||||
}
|
||||
if (item.type === 'convo') {
|
||||
return `project-workspace-convo-${item.convo.conversationId}`;
|
||||
}
|
||||
return `project-workspace-${item.type}`;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const listRef = useRef<List | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
cache.clearAll();
|
||||
listRef.current?.recomputeRowHeights(0);
|
||||
});
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [cache, conversations.length, sortBy]);
|
||||
|
||||
const throttledLoadMore = useMemo(() => throttle(loadMore, 300), [loadMore]);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ index, key, parent, style }) => {
|
||||
const item = flattenedItems[index];
|
||||
const rowProps = { cache, rowKey: key, parent, index, style };
|
||||
|
||||
if (item.type === 'loading') {
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<LoadingRow />
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'empty') {
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<div className="py-12 text-center text-sm text-text-secondary">{emptyLabel}</div>
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'date') {
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<DateLabel groupName={item.groupName} />
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<ConversationRow conversation={item.convo} />
|
||||
</MeasuredRow>
|
||||
);
|
||||
},
|
||||
[cache, emptyLabel, flattenedItems],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
({ index }: { index: number }) => cache.getHeight(index, 0),
|
||||
[cache],
|
||||
);
|
||||
|
||||
const handleRowsRendered = useCallback(
|
||||
({ stopIndex }: { stopIndex: number }) => {
|
||||
if (hasNextPage && stopIndex >= flattenedItems.length - 6) {
|
||||
throttledLoadMore();
|
||||
}
|
||||
},
|
||||
[flattenedItems.length, hasNextPage, throttledLoadMore],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-[280px] flex-1 overflow-hidden rounded-lg border border-border-light')}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={listRef}
|
||||
width={width}
|
||||
height={height}
|
||||
rowCount={flattenedItems.length}
|
||||
rowHeight={getRowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
deferredMeasurementCache={cache}
|
||||
overscanRowCount={8}
|
||||
onRowsRendered={handleRowsRendered}
|
||||
className="outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ProjectChatList);
|
||||
120
client/src/components/Projects/ProjectCreateDialog.tsx
Normal file
120
client/src/components/Projects/ProjectCreateDialog.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import { useCreateProjectMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ProjectCreateDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (project: TChatProject) => void;
|
||||
children?: ReactNode;
|
||||
triggerRef?: MutableRefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
export default function ProjectCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
children,
|
||||
triggerRef,
|
||||
}: ProjectCreateDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const formId = useId();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const createProject = useCreateProjectMutation();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const frameId = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen && !createProject.isLoading) {
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || createProject.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({ name: trimmedName });
|
||||
setName('');
|
||||
onOpenChange(false);
|
||||
onCreated?.(project);
|
||||
} catch {
|
||||
showToast({
|
||||
message: localize('com_ui_project_create_error'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={handleOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_create_project')}
|
||||
showCloseButton={true}
|
||||
className="w-11/12 max-w-lg bg-surface-primary text-text-primary"
|
||||
main={
|
||||
<form id={formId} onSubmit={handleCreate} className="space-y-2">
|
||||
<Label htmlFor={`${formId}-name`} className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_project_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${formId}-name`}
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={localize('com_ui_project_name_placeholder')}
|
||||
className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="submit"
|
||||
disabled={!name.trim() || createProject.isLoading}
|
||||
aria-label={localize('com_ui_create_project')}
|
||||
>
|
||||
{createProject.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
localize('com_ui_create_project')
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
215
client/src/components/Projects/ProjectWorkspace.tsx
Normal file
215
client/src/components/Projects/ProjectWorkspace.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, ArrowUpDown, Check, Folder, Plus } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import { Spinner, DropdownPopup } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import { useConversationsInfiniteQuery, useProjectQuery } from '~/data-provider';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { cn, clearMessagesCache } from '~/utils';
|
||||
import ProjectChatList from './ProjectChatList';
|
||||
import store from '~/store';
|
||||
|
||||
type ChatSortField = 'updatedAt' | 'createdAt';
|
||||
|
||||
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
|
||||
return function SortMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={cn(className, 'justify-between gap-5')}>
|
||||
<span className="truncate">{label}</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProjectWorkspace() {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { projectId = '' } = useParams();
|
||||
const [sortBy, setSortBy] = useState<ChatSortField>('updatedAt');
|
||||
const sortMenuId = useId();
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const { newConversation } = useNewConvo();
|
||||
const activeProjectId = project?._id;
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'updatedAt' as const, label: localize('com_ui_sort_updated') },
|
||||
{ value: 'createdAt' as const, label: localize('com_ui_sort_created') },
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
const selectedSortLabel =
|
||||
sortOptions.find((option) => option.value === sortBy)?.label ?? localize('com_ui_sort_updated');
|
||||
const sortMenuItems = useMemo<MenuItemProps[]>(
|
||||
() =>
|
||||
sortOptions.map((option) => {
|
||||
const isSelected = sortBy === option.value;
|
||||
return {
|
||||
id: `project-chat-sort-${option.value}`,
|
||||
ariaLabel: option.label,
|
||||
ariaChecked: isSelected,
|
||||
onClick: () => setSortBy(option.value),
|
||||
render: renderSortMenuItem(option.label, isSelected),
|
||||
};
|
||||
}),
|
||||
[sortBy, sortOptions],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: isConversationsLoading,
|
||||
} = useConversationsInfiniteQuery(
|
||||
{
|
||||
projectId: activeProjectId,
|
||||
sortBy,
|
||||
sortDirection: 'desc',
|
||||
},
|
||||
{
|
||||
enabled: Boolean(activeProjectId),
|
||||
staleTime: 30000,
|
||||
cacheTime: 300000,
|
||||
},
|
||||
);
|
||||
|
||||
const conversations = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.conversations) ?? [],
|
||||
[data?.pages],
|
||||
);
|
||||
|
||||
const hasNextPage = useMemo(() => {
|
||||
const pages = data?.pages;
|
||||
if (!pages?.length) {
|
||||
return false;
|
||||
}
|
||||
const lastPage: ConversationListResponse = pages[pages.length - 1];
|
||||
return lastPage.nextCursor !== null;
|
||||
}, [data?.pages]);
|
||||
|
||||
const startProjectChat = useCallback(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation({ template: { chatProjectId: activeProjectId } });
|
||||
}, [activeProjectId, conversation?.conversationId, newConversation, queryClient]);
|
||||
|
||||
if (isProjectLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-secondary">
|
||||
{localize('com_ui_project_not_found')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 flex-col overflow-y-auto bg-surface-primary text-text-primary">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 pb-10 pt-4 md:px-6 lg:pt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="-ml-1.5 inline-flex w-fit items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_all_projects')}
|
||||
</button>
|
||||
|
||||
<header className="mt-5 flex items-start gap-3">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-surface-secondary text-text-secondary">
|
||||
<Folder className="h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text-primary">
|
||||
{project.name}
|
||||
</h1>
|
||||
{project.description ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-sm text-text-secondary">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startProjectChat}
|
||||
className={cn(
|
||||
'mt-6 flex w-full items-center gap-3 rounded-[26px] border border-border-medium bg-surface-secondary px-3.5 py-3 text-left shadow-sm transition-colors',
|
||||
'hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary text-text-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-text-secondary">
|
||||
{localize('com_ui_new_chat_in_project', { name: project.name })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<section className="mt-8 flex min-h-0 flex-1 flex-col">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-baseline gap-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_chats')}
|
||||
<span className="text-text-secondary">{project.conversationCount}</span>
|
||||
</h2>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
menuId={sortMenuId}
|
||||
isOpen={isSortMenuOpen}
|
||||
setIsOpen={setIsSortMenuOpen}
|
||||
className="z-[125] min-w-44"
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={localize('com_ui_sort_chats_by')}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
isSortMenuOpen && 'bg-surface-hover text-text-primary',
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" aria-hidden="true" />
|
||||
{selectedSortLabel}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={sortMenuItems}
|
||||
/>
|
||||
</div>
|
||||
<ProjectChatList
|
||||
conversations={conversations}
|
||||
isLoading={isConversationsLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
sortBy={sortBy}
|
||||
emptyLabel={localize('com_ui_no_project_chats')}
|
||||
loadMore={() => fetchNextPage()}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
239
client/src/components/Projects/ProjectsView.tsx
Normal file
239
client/src/components/Projects/ProjectsView.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useDeferredValue, useEffect, useId, useMemo, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ArrowUpDown, Check, Folder, Plus, Search } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import { Input, Button, Spinner, DropdownPopup } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import { useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import ProjectCreateDialog from './ProjectCreateDialog';
|
||||
|
||||
type ProjectSort = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
|
||||
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
|
||||
return function SortMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={cn(className, 'justify-between gap-5')}>
|
||||
<span className="truncate">{label}</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function formatActivity(project: TChatProject) {
|
||||
const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export default function ProjectsView() {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<ProjectSort>('lastConversationAt');
|
||||
const [isCreating, setIsCreating] = useState(searchParams.get('new') === '1');
|
||||
const sortMenuId = useId();
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, isLoading } = useProjectsInfiniteQuery({
|
||||
search: deferredSearch || undefined,
|
||||
sortBy,
|
||||
sortDirection: sortBy === 'name' ? 'asc' : 'desc',
|
||||
});
|
||||
|
||||
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
|
||||
const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'lastConversationAt' as const, label: localize('com_ui_latest_activity') },
|
||||
{ value: 'createdAt' as const, label: localize('com_ui_sort_created') },
|
||||
{ value: 'name' as const, label: localize('com_ui_name') },
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
const selectedSortLabel =
|
||||
sortOptions.find((option) => option.value === sortBy)?.label ??
|
||||
localize('com_ui_latest_activity');
|
||||
const sortMenuItems = useMemo<MenuItemProps[]>(
|
||||
() =>
|
||||
sortOptions.map((option) => {
|
||||
const isSelected = sortBy === option.value;
|
||||
return {
|
||||
id: `project-sort-${option.value}`,
|
||||
ariaLabel: option.label,
|
||||
ariaChecked: isSelected,
|
||||
onClick: () => setSortBy(option.value),
|
||||
render: renderSortMenuItem(option.label, isSelected),
|
||||
};
|
||||
}),
|
||||
[sortBy, sortOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('new') === '1') {
|
||||
setIsCreating(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleCreateDialogChange = (open: boolean) => {
|
||||
setIsCreating(open);
|
||||
if (!open && searchParams.get('new') === '1') {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete('new');
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 flex-col overflow-auto bg-surface-primary text-text-primary">
|
||||
<div className="container mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 md:px-6 lg:pt-12">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-primary md:text-3xl">
|
||||
{localize('com_ui_projects')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden text-sm text-text-secondary sm:inline">
|
||||
{localize('com_ui_sort_by')}
|
||||
</span>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
menuId={sortMenuId}
|
||||
isOpen={isSortMenuOpen}
|
||||
setIsOpen={setIsSortMenuOpen}
|
||||
className="z-[125] min-w-56"
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={localize('com_ui_sort_projects_by')}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-border-medium bg-surface-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:pointer-events-none disabled:opacity-50 sm:w-44',
|
||||
isSortMenuOpen && 'bg-surface-hover text-text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ArrowUpDown
|
||||
className="h-4 w-4 shrink-0 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{selectedSortLabel}</span>
|
||||
</span>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={sortMenuItems}
|
||||
/>
|
||||
<Button type="button" variant="submit" size="sm" onClick={() => setIsCreating(true)}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_new_project')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<span className="sr-only">{localize('com_ui_search_projects')}</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={localize('com_ui_search_projects')}
|
||||
className="border-border-medium bg-surface-secondary pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<span className="rounded-full bg-surface-active-alt px-4 py-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_your_projects')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectCreateDialog
|
||||
open={isCreating}
|
||||
onOpenChange={handleCreateDialogChange}
|
||||
onCreated={(project) => navigate(`/projects/${project._id}`)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 md:gap-4">
|
||||
{projects.map((project) => {
|
||||
const activity = formatActivity(project);
|
||||
return (
|
||||
<button
|
||||
key={project._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'group/project flex min-h-[8.5rem] flex-col rounded-xl border border-border-medium bg-surface-secondary p-4 text-left transition-colors',
|
||||
'hover:border-border-heavy hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
onClick={() => navigate(`/projects/${project._id}`)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<span className="truncate text-base font-semibold text-text-primary">
|
||||
{project.name}
|
||||
</span>
|
||||
</span>
|
||||
{project.description ? (
|
||||
<span className="mt-2 line-clamp-2 text-sm leading-relaxed text-text-secondary">
|
||||
{project.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs text-text-secondary">
|
||||
<span>
|
||||
{project.conversationCount === 1
|
||||
? localize('com_ui_project_chat_count_single')
|
||||
: localize('com_ui_project_chat_count', {
|
||||
count: project.conversationCount,
|
||||
})}
|
||||
</span>
|
||||
{activity ? <span className="shrink-0 truncate">{activity}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && projects.length === 0 && (
|
||||
<div className="rounded-lg border border-border-medium bg-transparent py-16 text-center text-sm text-text-secondary">
|
||||
{localize('com_ui_no_projects')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mx-auto"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
2
client/src/components/Projects/index.ts
Normal file
2
client/src/components/Projects/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ProjectsView } from './ProjectsView';
|
||||
export { default as ProjectWorkspace } from './ProjectWorkspace';
|
||||
|
|
@ -14,6 +14,8 @@ import {
|
|||
} from '~/hooks';
|
||||
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
|
||||
import { Conversations } from '~/components/Conversations';
|
||||
import ProjectsSection from '~/components/Conversations/ProjectsSection';
|
||||
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
|
||||
import SearchBar from '~/components/Nav/SearchBar';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -104,7 +106,7 @@ const ConversationsSection = memo(() => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden pb-3"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden pb-3 pt-2"
|
||||
role="region"
|
||||
aria-label={localize('com_ui_chat_history')}
|
||||
>
|
||||
|
|
@ -116,6 +118,12 @@ const ConversationsSection = memo(() => {
|
|||
)}
|
||||
{search.enabled && <SearchBar isSmallScreen={isSmallScreen} />}
|
||||
</div>
|
||||
{!search.query && (
|
||||
<div className="px-3">
|
||||
<FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />
|
||||
</div>
|
||||
)}
|
||||
{!search.query && <ProjectsSection toggleNav={toggleNav} isAuthenticated={isAuthenticated} />}
|
||||
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
|
|
@ -127,6 +135,7 @@ const ConversationsSection = memo(() => {
|
|||
isSearchLoading={isSearchLoading}
|
||||
isChatsExpanded={isChatsExpanded}
|
||||
setIsChatsExpanded={setIsChatsExpanded}
|
||||
showFavorites={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
2
client/src/data-provider/Projects/index.ts
Normal file
2
client/src/data-provider/Projects/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './queries';
|
||||
export * from './mutations';
|
||||
117
client/src/data-provider/Projects/mutations.ts
Normal file
117
client/src/data-provider/Projects/mutations.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useRecoilCallback } from 'recoil';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dataService, QueryKeys } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
TChatProject,
|
||||
TConversation,
|
||||
TCreateChatProjectRequest,
|
||||
TDeleteChatProjectResponse,
|
||||
TUpdateChatProjectRequest,
|
||||
TAssignConversationToProjectRequest,
|
||||
TAssignConversationToProjectResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
export const useCreateProjectMutation = (): UseMutationResult<
|
||||
TChatProject,
|
||||
unknown,
|
||||
TCreateChatProjectRequest,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((payload: TCreateChatProjectRequest) => dataService.createProject(payload), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProjectMutation = (): UseMutationResult<
|
||||
TChatProject,
|
||||
unknown,
|
||||
TUpdateChatProjectRequest,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((payload: TUpdateChatProjectRequest) => dataService.updateProject(payload), {
|
||||
onSuccess: (project) => {
|
||||
queryClient.setQueryData([QueryKeys.project, project._id], project);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteProjectMutation = (): UseMutationResult<
|
||||
TDeleteChatProjectResponse,
|
||||
unknown,
|
||||
string,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const clearActiveConversationProject = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
async (projectId: string) => {
|
||||
const conversation = await snapshot.getPromise(store.conversationByIndex(0));
|
||||
if (conversation?.conversationId && conversation.chatProjectId === projectId) {
|
||||
set(store.updateConversationSelector(conversation.conversationId), {
|
||||
...conversation,
|
||||
chatProjectId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
return useMutation((projectId: string) => dataService.deleteProject(projectId), {
|
||||
onSuccess: (_result, projectId) => {
|
||||
clearActiveConversationProject(projectId);
|
||||
queryClient.removeQueries([QueryKeys.project, projectId]);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignConversationToProjectMutation = (): UseMutationResult<
|
||||
TAssignConversationToProjectResponse,
|
||||
unknown,
|
||||
TAssignConversationToProjectRequest,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const updateActiveConversation = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(conversation: TConversation) => {
|
||||
if (!conversation.conversationId) {
|
||||
return;
|
||||
}
|
||||
set(store.updateConversationSelector(conversation.conversationId), {
|
||||
...conversation,
|
||||
chatProjectId: conversation.chatProjectId ?? null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useMutation(
|
||||
(payload: TAssignConversationToProjectRequest) =>
|
||||
dataService.assignConversationToProject(payload),
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
updateActiveConversation(result.conversation);
|
||||
queryClient.setQueryData(
|
||||
[QueryKeys.conversation, result.conversation.conversationId],
|
||||
result.conversation,
|
||||
);
|
||||
[result.previousProjectId, result.projectId].forEach((projectId) => {
|
||||
if (projectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.project, projectId]);
|
||||
}
|
||||
});
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
49
client/src/data-provider/Projects/queries.ts
Normal file
49
client/src/data-provider/Projects/queries.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { dataService, QueryKeys } from 'librechat-data-provider';
|
||||
import type {
|
||||
UseInfiniteQueryOptions,
|
||||
QueryObserverResult,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import type { ProjectListParams, ProjectListResponse, TChatProject } from 'librechat-data-provider';
|
||||
|
||||
export const useProjectsInfiniteQuery = (
|
||||
params: ProjectListParams = {},
|
||||
config?: UseInfiniteQueryOptions<ProjectListResponse, unknown>,
|
||||
) => {
|
||||
const { sortBy, sortDirection, search, limit } = params;
|
||||
|
||||
return useInfiniteQuery<ProjectListResponse>({
|
||||
queryKey: [QueryKeys.projects, { sortBy, sortDirection, search, limit }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
dataService.listProjects({
|
||||
sortBy,
|
||||
sortDirection,
|
||||
search,
|
||||
limit,
|
||||
cursor: pageParam?.toString(),
|
||||
}),
|
||||
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
|
||||
keepPreviousData: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
cacheTime: 30 * 60 * 1000,
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProjectQuery = (
|
||||
projectId?: string | null,
|
||||
config?: UseQueryOptions<TChatProject>,
|
||||
): QueryObserverResult<TChatProject, unknown> => {
|
||||
return useQuery<TChatProject>(
|
||||
[QueryKeys.project, projectId],
|
||||
() => dataService.getProjectById(projectId ?? ''),
|
||||
{
|
||||
enabled: Boolean(projectId),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
...config,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ export * from './Files';
|
|||
export * from './Memories';
|
||||
export * from './Messages';
|
||||
export * from './Misc';
|
||||
export * from './Projects';
|
||||
export * from './Tools';
|
||||
export * from './connection';
|
||||
export * from './Favorites';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
logger,
|
||||
/* Conversations */
|
||||
addConvoToAllQueries,
|
||||
findConversationInInfinite,
|
||||
updateConvoInAllQueries,
|
||||
removeConvoFromAllQueries,
|
||||
} from '~/utils';
|
||||
|
|
@ -34,6 +35,7 @@ export const useUpdateConversationMutation = (
|
|||
const targetId = payload.conversationId || id;
|
||||
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
|
||||
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -123,6 +125,9 @@ export const useArchiveConvoMutation = (
|
|||
[QueryKeys.conversation, vars.conversationId],
|
||||
isArchived ? null : _data,
|
||||
);
|
||||
if (_data.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.project, _data.chatProjectId]);
|
||||
}
|
||||
|
||||
onSuccess?.(_data, vars, context);
|
||||
},
|
||||
|
|
@ -136,6 +141,8 @@ export const useArchiveConvoMutation = (
|
|||
queryKey: archivedConvoQueryKey,
|
||||
refetchPage: (_, index) => index === 0,
|
||||
});
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
},
|
||||
..._options,
|
||||
},
|
||||
|
|
@ -475,6 +482,30 @@ export const useDeleteConversationMutation = (
|
|||
// TODO: CHECK THIS, no-op; restore if needed
|
||||
},
|
||||
onSuccess: (data, vars, context) => {
|
||||
const deletedConversation = vars.conversationId
|
||||
? queryClient.getQueryData<t.TConversation>([QueryKeys.conversation, vars.conversationId])
|
||||
: undefined;
|
||||
let deletedProjectId = deletedConversation?.chatProjectId;
|
||||
if (!deletedProjectId && vars.conversationId) {
|
||||
const cacheKeys = [QueryKeys.allConversations, QueryKeys.projectConversations];
|
||||
for (const cacheKey of cacheKeys) {
|
||||
const queries = queryClient.getQueryCache().findAll([cacheKey], { exact: false });
|
||||
for (const query of queries) {
|
||||
const found = findConversationInInfinite(
|
||||
queryClient.getQueryData<InfiniteData<ConversationListResponse>>(query.queryKey),
|
||||
vars.conversationId,
|
||||
);
|
||||
if (found?.chatProjectId) {
|
||||
deletedProjectId = found.chatProjectId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (deletedProjectId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vars.conversationId) {
|
||||
removeConvoFromAllQueries(queryClient, vars.conversationId);
|
||||
}
|
||||
|
|
@ -519,6 +550,11 @@ export const useDeleteConversationMutation = (
|
|||
queryKey: [QueryKeys.archivedConversations],
|
||||
refetchPage: (_, index) => index === 0,
|
||||
});
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
if (deletedProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.project, deletedProjectId]);
|
||||
}
|
||||
|
||||
options?.onSuccess?.(data, vars, context);
|
||||
},
|
||||
|
|
@ -550,6 +586,11 @@ export const useDuplicateConversationMutation = (
|
|||
queryKey: [QueryKeys.allConversations],
|
||||
refetchPage: (_, index) => index === 0,
|
||||
});
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
if (duplicatedConversation.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.project, duplicatedConversation.chatProjectId]);
|
||||
}
|
||||
|
||||
if (duplicatedConversation.tags && duplicatedConversation.tags.length > 0) {
|
||||
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (oldTags) => {
|
||||
|
|
@ -593,6 +634,11 @@ export const useForkConvoMutation = (
|
|||
queryKey: [QueryKeys.allConversations],
|
||||
refetchPage: (_, index) => index === 0,
|
||||
});
|
||||
queryClient.invalidateQueries([QueryKeys.projectConversations]);
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
if (forkedConversation.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.project, forkedConversation.chatProjectId]);
|
||||
}
|
||||
|
||||
if (forkedConversation.tags && forkedConversation.tags.length > 0) {
|
||||
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (oldTags) => {
|
||||
|
|
|
|||
|
|
@ -86,12 +86,12 @@ export const useConversationsInfiniteQuery = (
|
|||
params: ConversationListParams,
|
||||
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
|
||||
) => {
|
||||
const { isArchived, sortBy, sortDirection, tags, search } = params;
|
||||
const { isArchived, sortBy, sortDirection, tags, search, projectId } = params;
|
||||
|
||||
return useInfiniteQuery<ConversationListResponse>({
|
||||
queryKey: [
|
||||
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
|
||||
{ isArchived, sortBy, sortDirection, tags, search },
|
||||
{ isArchived, sortBy, sortDirection, tags, search, projectId },
|
||||
],
|
||||
queryFn: ({ pageParam }) =>
|
||||
dataService.listConversations({
|
||||
|
|
@ -100,6 +100,7 @@ export const useConversationsInfiniteQuery = (
|
|||
sortDirection,
|
||||
tags,
|
||||
search,
|
||||
projectId,
|
||||
cursor: pageParam?.toString(),
|
||||
}),
|
||||
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import type { SetterOrUpdater } from 'recoil';
|
|||
import type { TAskFunction, ExtendedFile } from '~/common';
|
||||
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
import { logger, createDualMessageContent } from '~/utils';
|
||||
import { logger, createDualMessageContent, getRouteChatProjectId } from '~/utils';
|
||||
import store, { useGetEphemeralAgent } from '~/store';
|
||||
import { startupConfigKey } from '~/data-provider';
|
||||
import useUserKey from '~/hooks/Input/useUserKey';
|
||||
|
|
@ -174,6 +174,13 @@ export default function useChatFunctions({
|
|||
});
|
||||
}
|
||||
|
||||
const chatProjectId =
|
||||
conversationId === Constants.NEW_CONVO
|
||||
? getRouteChatProjectId()
|
||||
: (conversation?.chatProjectId ?? null);
|
||||
const conversationForPayload =
|
||||
chatProjectId != null ? { ...(conversation ?? {}), chatProjectId } : (conversation ?? {});
|
||||
|
||||
// construct the query message
|
||||
// this is not a real messageId, it is used as placeholder before real messageId returned
|
||||
const intermediateId = overrideUserMessageId ?? v4();
|
||||
|
|
@ -193,7 +200,8 @@ export default function useChatFunctions({
|
|||
parentMessageId = Constants.NO_PARENT;
|
||||
currentMessages = [];
|
||||
conversationId = null;
|
||||
navigate('/c/new', { state: { focusChat: true } });
|
||||
const projectSearch = chatProjectId ? `?projectId=${encodeURIComponent(chatProjectId)}` : '';
|
||||
navigate(`/c/new${projectSearch}`, { state: { focusChat: true } });
|
||||
}
|
||||
|
||||
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
|
||||
|
|
@ -221,7 +229,7 @@ export default function useChatFunctions({
|
|||
const convo = parseCompactConvo({
|
||||
endpoint: endpoint as EndpointSchemaKey,
|
||||
endpointType: endpointType as EndpointSchemaKey,
|
||||
conversation: conversation ?? {},
|
||||
conversation: conversationForPayload,
|
||||
defaultParamsEndpoint,
|
||||
});
|
||||
|
||||
|
|
@ -234,6 +242,7 @@ export default function useChatFunctions({
|
|||
overrideUserMessageId,
|
||||
},
|
||||
convo,
|
||||
chatProjectId ? { chatProjectId } : {},
|
||||
) as TEndpointOption;
|
||||
if (endpoint !== EModelEndpoint.agents) {
|
||||
endpointOption.key = getExpiry();
|
||||
|
|
@ -371,6 +380,7 @@ export default function useChatFunctions({
|
|||
const submission: TSubmission = {
|
||||
conversation: {
|
||||
...conversation,
|
||||
...(chatProjectId ? { chatProjectId } : {}),
|
||||
conversationId,
|
||||
},
|
||||
endpointOption,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { startupConfigKey, useGetAgentByIdQuery } from '~/data-provider';
|
|||
import { useChatContext, useChatFormContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
const PROJECT_ID_SEARCH_PARAM = 'projectId';
|
||||
|
||||
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
|
||||
const editCacheKey = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }];
|
||||
const editCache = queryClient.getQueryData<AgentListResponse>(editCacheKey);
|
||||
|
|
@ -72,6 +74,15 @@ export default function useQueryParams({
|
|||
const urlAgentId = searchParams.get('agent_id') || '';
|
||||
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId);
|
||||
|
||||
const getPreservedSearchParams = useCallback(() => {
|
||||
const preservedParams = new URLSearchParams();
|
||||
const projectId = searchParams.get(PROJECT_ID_SEARCH_PARAM);
|
||||
if (projectId) {
|
||||
preservedParams.set(PROJECT_ID_SEARCH_PARAM, projectId);
|
||||
}
|
||||
return preservedParams;
|
||||
}, [searchParams]);
|
||||
|
||||
/**
|
||||
* Applies settings from URL query parameters to create a new conversation.
|
||||
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
|
||||
|
|
@ -167,7 +178,11 @@ export default function useQueryParams({
|
|||
return;
|
||||
}
|
||||
|
||||
newConversation({ preset: newPreset, keepAddedConvos: true });
|
||||
newConversation({
|
||||
template: { chatProjectId: conversation?.chatProjectId ?? null },
|
||||
preset: newPreset,
|
||||
keepAddedConvos: true,
|
||||
});
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
|
|
@ -223,8 +238,8 @@ export default function useQueryParams({
|
|||
}
|
||||
})();
|
||||
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}, [methods, submitMessage, setSearchParams]);
|
||||
setSearchParams(getPreservedSearchParams(), { replace: true });
|
||||
}, [methods, submitMessage, setSearchParams, getPreservedSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const processQueryParams = () => {
|
||||
|
|
@ -239,6 +254,7 @@ export default function useQueryParams({
|
|||
delete queryParams.prompt;
|
||||
delete queryParams.q;
|
||||
delete queryParams.submit;
|
||||
delete queryParams[PROJECT_ID_SEARCH_PARAM];
|
||||
const validSettings = processValidSettings(queryParams);
|
||||
|
||||
return { decodedPrompt, validSettings, shouldAutoSubmit };
|
||||
|
|
@ -281,7 +297,7 @@ export default function useQueryParams({
|
|||
|
||||
// Defer URL cleanup until after submission completes (processSubmission handles it)
|
||||
if (!pendingSubmitRef.current) {
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
setSearchParams(getPreservedSearchParams(), { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -349,6 +365,7 @@ export default function useQueryParams({
|
|||
newConversation,
|
||||
submitMessage,
|
||||
setSearchParams,
|
||||
getPreservedSearchParams,
|
||||
queryClient,
|
||||
processSubmission,
|
||||
areSettingsApplied,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type {
|
||||
TPreset,
|
||||
|
|
@ -39,6 +40,16 @@ export default function useSelectMention({
|
|||
const getDefaultConversation = useDefaultConvo();
|
||||
const modularChat = useRecoilValue(store.modularChat);
|
||||
const availableTools = useRecoilValue(store.availableTools);
|
||||
const [searchParams] = useSearchParams();
|
||||
/**
|
||||
* Project scope is read from React Router's search params (the same source the
|
||||
* project chip reads), not window.location — new-chat params are written to the
|
||||
* URL via raw history.pushState, so window.location can drift out of sync.
|
||||
*/
|
||||
const routeChatProjectId = useMemo(() => {
|
||||
const projectId = searchParams.get('projectId');
|
||||
return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
|
||||
}, [searchParams]);
|
||||
|
||||
const onSelectSpec = useCallback(
|
||||
(spec?: TModelSpec) => {
|
||||
|
|
@ -107,7 +118,10 @@ export default function useSelectMention({
|
|||
|
||||
logger.info('conversation', 'Switching conversation to new spec', conversation);
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
template: {
|
||||
...(template as Partial<TConversation>),
|
||||
chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
|
||||
},
|
||||
preset,
|
||||
keepAddedConvos: isModular,
|
||||
});
|
||||
|
|
@ -119,6 +133,7 @@ export default function useSelectMention({
|
|||
newConversation,
|
||||
endpointsConfig,
|
||||
assistantsMap,
|
||||
routeChatProjectId,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -201,12 +216,22 @@ export default function useSelectMention({
|
|||
|
||||
logger.info('conversation', 'Switching conversation to new endpoint/model', template);
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
template: {
|
||||
...(template as Partial<TConversation>),
|
||||
chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
|
||||
},
|
||||
preset: { ...kwargs, spec: null, iconURL: null, modelLabel: null, endpoint: newEndpoint },
|
||||
keepAddedConvos: isNewModular,
|
||||
});
|
||||
},
|
||||
[getConversation, getDefaultConversation, modularChat, newConversation, endpointsConfig],
|
||||
[
|
||||
getConversation,
|
||||
getDefaultConversation,
|
||||
modularChat,
|
||||
newConversation,
|
||||
endpointsConfig,
|
||||
routeChatProjectId,
|
||||
],
|
||||
);
|
||||
|
||||
const onSelectPreset = useCallback(
|
||||
|
|
@ -264,6 +289,7 @@ export default function useSelectMention({
|
|||
|
||||
logger.info('conversation', 'Switching conversation to new preset', template);
|
||||
newConversation({
|
||||
template: { chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null },
|
||||
preset: newPreset,
|
||||
keepAddedConvos: isModular,
|
||||
disableParams,
|
||||
|
|
@ -276,6 +302,7 @@ export default function useSelectMention({
|
|||
newConversation,
|
||||
endpointsConfig,
|
||||
getDefaultConversation,
|
||||
routeChatProjectId,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ export default function useTextarea({
|
|||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const getSender = useGetSender();
|
||||
|
|
@ -95,6 +97,10 @@ export default function useTextarea({
|
|||
return localize('com_endpoint_message_not_appendable');
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const sender =
|
||||
isAssistant || isAgent
|
||||
? getEntityName({ name: entityName, isAgent, localize })
|
||||
|
|
@ -105,17 +111,17 @@ export default function useTextarea({
|
|||
})}`;
|
||||
};
|
||||
|
||||
const placeholder = getPlaceholderText();
|
||||
const placeholderText = getPlaceholderText();
|
||||
|
||||
if (textAreaRef.current?.getAttribute('placeholder') === placeholder) {
|
||||
if (textAreaRef.current?.getAttribute('placeholder') === placeholderText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setPlaceholder = () => {
|
||||
const placeholder = getPlaceholderText();
|
||||
const placeholderText = getPlaceholderText();
|
||||
|
||||
if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
|
||||
textAreaRef.current?.setAttribute('placeholder', placeholder);
|
||||
if (textAreaRef.current?.getAttribute('placeholder') !== placeholderText) {
|
||||
textAreaRef.current?.setAttribute('placeholder', placeholderText);
|
||||
forceResize(textAreaRef.current);
|
||||
}
|
||||
};
|
||||
|
|
@ -137,6 +143,7 @@ export default function useTextarea({
|
|||
conversation,
|
||||
latestMessage,
|
||||
isNotAppendable,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
|
|
|||
|
|
@ -373,6 +373,10 @@ export default function useEventHandlers({
|
|||
} else {
|
||||
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
|
||||
}
|
||||
if (update.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
|
||||
}
|
||||
} else if (setConversation) {
|
||||
setConversation((prevState) => {
|
||||
update = tConvoUpdateSchema.parse({
|
||||
|
|
@ -448,6 +452,10 @@ export default function useEventHandlers({
|
|||
} else {
|
||||
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
|
||||
}
|
||||
if (update.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
|
||||
}
|
||||
}
|
||||
} else if (setConversation) {
|
||||
setConversation((prevState) => {
|
||||
|
|
@ -720,6 +728,11 @@ export default function useEventHandlers({
|
|||
});
|
||||
}
|
||||
|
||||
if (conversation.chatProjectId) {
|
||||
queryClient.invalidateQueries([QueryKeys.projects]);
|
||||
queryClient.invalidateQueries([QueryKeys.project, conversation.chatProjectId]);
|
||||
}
|
||||
|
||||
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
|
||||
preserveSubagentAtomsForNewConvoIdRef.current = conversation.conversationId;
|
||||
navigate(`/c/${conversation.conversationId}`, { replace: true });
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ const useNewConvo = (index = 0) => {
|
|||
const modelsConfig = modelsData ?? modelsQuery.data;
|
||||
const { endpoint = null } = conversation;
|
||||
const buildDefaultConversation = (endpoint === null || buildDefault) ?? false;
|
||||
const hasExplicitChatProjectId = Object.prototype.hasOwnProperty.call(
|
||||
conversation,
|
||||
'chatProjectId',
|
||||
);
|
||||
const explicitChatProjectId = conversation.chatProjectId;
|
||||
const activePreset =
|
||||
// use default preset only when it's defined,
|
||||
// preset is not provided,
|
||||
|
|
@ -198,6 +203,12 @@ const useNewConvo = (index = 0) => {
|
|||
models,
|
||||
defaultParamsEndpoint,
|
||||
});
|
||||
|
||||
if (hasExplicitChatProjectId) {
|
||||
conversation.chatProjectId = explicitChatProjectId ?? null;
|
||||
} else {
|
||||
delete conversation.chatProjectId;
|
||||
}
|
||||
}
|
||||
|
||||
if (disableParams === true) {
|
||||
|
|
@ -227,26 +238,44 @@ const useNewConvo = (index = 0) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const searchParamsString = searchParams?.toString();
|
||||
const getParams = () => (searchParamsString ? `?${searchParamsString}` : '');
|
||||
const getParams = (nextConversation: TConversation) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete('projectId');
|
||||
if (
|
||||
nextConversation.conversationId === Constants.NEW_CONVO &&
|
||||
nextConversation.chatProjectId
|
||||
) {
|
||||
nextParams.set('projectId', nextConversation.chatProjectId);
|
||||
}
|
||||
|
||||
const searchParamsString = nextParams.toString();
|
||||
return searchParamsString ? `?${searchParamsString}` : '';
|
||||
};
|
||||
|
||||
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
|
||||
const appTitle = localStorage.getItem(LocalStorageKeys.APP_TITLE) ?? '';
|
||||
if (appTitle) {
|
||||
document.title = appTitle;
|
||||
}
|
||||
const path = `/c/${Constants.NEW_CONVO}${getParams()}`;
|
||||
const path = `/c/${Constants.NEW_CONVO}${getParams(conversation)}`;
|
||||
navigate(path, { state: { focusChat: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
const path = `/c/${conversation.conversationId}${getParams()}`;
|
||||
const path = `/c/${conversation.conversationId}${getParams(conversation)}`;
|
||||
navigate(path, {
|
||||
replace: true,
|
||||
state: disableFocus ? {} : { focusChat: true },
|
||||
});
|
||||
},
|
||||
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
|
||||
[
|
||||
endpointsConfig,
|
||||
defaultPreset,
|
||||
assistantsListMap,
|
||||
modelsQuery.data,
|
||||
hasAgentAccess,
|
||||
searchParams,
|
||||
],
|
||||
);
|
||||
|
||||
const newConversation = useCallback(
|
||||
|
|
@ -278,7 +307,7 @@ const useNewConvo = (index = 0) => {
|
|||
isParamEndpoint(_preset?.endpoint ?? '', _preset?.endpointType ?? '');
|
||||
const template =
|
||||
paramEndpoint === true && templateConvoId && templateConvoId === Constants.NEW_CONVO
|
||||
? { endpoint: _template.endpoint }
|
||||
? { endpoint: _template.endpoint, chatProjectId: _template.chatProjectId }
|
||||
: _template;
|
||||
|
||||
const conversation = {
|
||||
|
|
@ -298,7 +327,8 @@ const useNewConvo = (index = 0) => {
|
|||
startupConfig &&
|
||||
(startupConfig.modelSpecs?.prioritize === true ||
|
||||
(startupConfig.interface?.modelSelect ?? true) !== true ||
|
||||
(result?.last != null && Object.keys(_template).length === 0)) &&
|
||||
(result?.last != null &&
|
||||
Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0)) &&
|
||||
defaultModelSpec
|
||||
) {
|
||||
preset = getModelSpecPreset(defaultModelSpec);
|
||||
|
|
|
|||
|
|
@ -750,6 +750,7 @@
|
|||
"com_ui_agents_allow_share_public": "Allow sharing Agents publicly",
|
||||
"com_ui_agents_allow_use": "Allow using Agents",
|
||||
"com_ui_all": "all",
|
||||
"com_ui_all_projects": "All projects",
|
||||
"com_ui_all_proper": "All",
|
||||
"com_ui_analyzing": "Analyzing",
|
||||
"com_ui_analyzing_finished": "Finished analyzing",
|
||||
|
|
@ -840,6 +841,7 @@
|
|||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
"com_ui_category": "Category",
|
||||
"com_ui_change_project": "Change project",
|
||||
"com_ui_change_version": "Change Version",
|
||||
"com_ui_chat": "Chat",
|
||||
"com_ui_chat_history": "Chat History",
|
||||
|
|
@ -910,6 +912,7 @@
|
|||
"com_ui_create_mcp_server": "Create MCP server",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
"com_ui_create_new_agent": "Create New Agent",
|
||||
"com_ui_create_project": "Create project",
|
||||
"com_ui_create_prompt": "Create Prompt",
|
||||
"com_ui_create_prompt_page": "New Prompt Configuration Page",
|
||||
"com_ui_create_skill": "Create Skill",
|
||||
|
|
@ -961,6 +964,8 @@
|
|||
"com_ui_delete_memory": "Delete Memory",
|
||||
"com_ui_delete_not_allowed": "Delete operation is not allowed",
|
||||
"com_ui_delete_preset": "Delete Preset?",
|
||||
"com_ui_delete_project": "Delete project?",
|
||||
"com_ui_delete_project_confirm": "Delete \"{{name}}\"? The chats inside won't be deleted.",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_shared_link_heading": "Delete Shared Link",
|
||||
|
|
@ -1140,6 +1145,7 @@
|
|||
"com_ui_last_used": "Last used",
|
||||
"com_ui_late_night": "Happy late night",
|
||||
"com_ui_latest": "latest",
|
||||
"com_ui_latest_activity": "Latest activity",
|
||||
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||
"com_ui_latest_version": "Latest version",
|
||||
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
|
||||
|
|
@ -1149,6 +1155,7 @@
|
|||
"com_ui_link_copied": "Link copied",
|
||||
"com_ui_link_refreshed": "Link refreshed",
|
||||
"com_ui_live": "live",
|
||||
"com_ui_load_more": "Load more",
|
||||
"com_ui_loading": "Loading...",
|
||||
"com_ui_locked": "Locked",
|
||||
"com_ui_logo": "{{0}} Logo",
|
||||
|
|
@ -1251,6 +1258,7 @@
|
|||
"com_ui_model_selected": "{{0}} selected",
|
||||
"com_ui_model_spec": "Model Spec",
|
||||
"com_ui_more_info": "More info",
|
||||
"com_ui_more_options": "More options",
|
||||
"com_ui_my_prompts": "My Prompts",
|
||||
"com_ui_my_skills": "My Skills",
|
||||
"com_ui_n_files": "{{0}} files",
|
||||
|
|
@ -1259,7 +1267,9 @@
|
|||
"com_ui_navigate_results": "Navigate results",
|
||||
"com_ui_new": "New",
|
||||
"com_ui_new_chat": "New chat",
|
||||
"com_ui_new_chat_in_project": "New chat in {{name}}",
|
||||
"com_ui_new_conversation_title": "New Conversation Title",
|
||||
"com_ui_new_project": "New project",
|
||||
"com_ui_next": "Next",
|
||||
"com_ui_next_result": "Next result",
|
||||
"com_ui_no": "No",
|
||||
|
|
@ -1279,6 +1289,8 @@
|
|||
"com_ui_no_memories_match": "No memories match your search",
|
||||
"com_ui_no_memories_title": "No memories yet",
|
||||
"com_ui_no_personalization_available": "No personalization options are currently available",
|
||||
"com_ui_no_project_chats": "No chats yet",
|
||||
"com_ui_no_projects": "No projects yet",
|
||||
"com_ui_no_prompts_title": "No prompts yet",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
|
|
@ -1307,6 +1319,7 @@
|
|||
"com_ui_omitted": "Omitted",
|
||||
"com_ui_on": "On",
|
||||
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
|
||||
"com_ui_open_project": "Open project",
|
||||
"com_ui_open_source_chat_new_tab": "Open Source Chat in New Tab",
|
||||
"com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}",
|
||||
"com_ui_open_var": "Open {{0}}",
|
||||
|
|
@ -1337,6 +1350,17 @@
|
|||
"com_ui_privacy_policy": "Privacy policy",
|
||||
"com_ui_privacy_policy_url": "Privacy Policy URL",
|
||||
"com_ui_production": "Production",
|
||||
"com_ui_project_chat_count": "{{count}} chats",
|
||||
"com_ui_project_chat_count_single": "1 chat",
|
||||
"com_ui_project_create_error": "Failed to create project",
|
||||
"com_ui_project_delete_error": "Failed to delete project",
|
||||
"com_ui_project_name": "Project name",
|
||||
"com_ui_project_name_placeholder": "Copenhagen Trip",
|
||||
"com_ui_project_not_found": "Project not found",
|
||||
"com_ui_project_rename_error": "Failed to rename project",
|
||||
"com_ui_project_update_error": "Failed to update project assignment",
|
||||
"com_ui_project_updated": "Project assignment updated",
|
||||
"com_ui_projects": "Projects",
|
||||
"com_ui_prompt": "Prompt",
|
||||
"com_ui_prompt_category_selector_aria": "Prompt's category selector",
|
||||
"com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
|
||||
|
|
@ -1393,11 +1417,13 @@
|
|||
"com_ui_remote_agents_allow_share_public": "Allow users to grant API access to agents to all users",
|
||||
"com_ui_remote_agents_allow_use": "Allow users to create API keys and query agents remotely",
|
||||
"com_ui_remove_agent_from_chain": "Remove {{0}} from chain",
|
||||
"com_ui_remove_from_project": "Remove from project",
|
||||
"com_ui_remove_skill_var": "Remove {{0}}",
|
||||
"com_ui_remove_user": "Remove {{0}}",
|
||||
"com_ui_rename": "Rename",
|
||||
"com_ui_rename_conversation": "Rename Conversation",
|
||||
"com_ui_rename_failed": "Failed to rename conversation",
|
||||
"com_ui_rename_project": "Rename project",
|
||||
"com_ui_requires_auth": "Requires Authentication",
|
||||
"com_ui_reset": "Reset",
|
||||
"com_ui_reset_adjustments": "Reset adjustments",
|
||||
|
|
@ -1454,6 +1480,7 @@
|
|||
"com_ui_search_agent_category": "Search categories...",
|
||||
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
|
||||
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
|
||||
"com_ui_search_projects": "Search projects",
|
||||
"com_ui_search_result_count": "{{count}} result found",
|
||||
"com_ui_search_results_count": "{{count}} results found",
|
||||
"com_ui_search_skills": "Search skills...",
|
||||
|
|
@ -1467,6 +1494,7 @@
|
|||
"com_ui_select_model": "Select a model",
|
||||
"com_ui_select_options": "Select options...",
|
||||
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
|
||||
"com_ui_select_project": "Select project",
|
||||
"com_ui_select_provider": "Select a provider",
|
||||
"com_ui_select_provider_first": "Select a provider first",
|
||||
"com_ui_select_region": "Select a region",
|
||||
|
|
@ -1574,6 +1602,11 @@
|
|||
"com_ui_skills_load_error": "Failed to load skills",
|
||||
"com_ui_skills_manual_invoked": "Manually invoked skills",
|
||||
"com_ui_skills_queued": "Skills queued for next submission",
|
||||
"com_ui_sort_by": "Sort by",
|
||||
"com_ui_sort_chats_by": "Sort chats by",
|
||||
"com_ui_sort_created": "Created",
|
||||
"com_ui_sort_projects_by": "Sort projects by",
|
||||
"com_ui_sort_updated": "Updated",
|
||||
"com_ui_special": "special",
|
||||
"com_ui_special_var_current_date": "Current Date",
|
||||
"com_ui_special_var_current_datetime": "Current Date & Time",
|
||||
|
|
@ -1654,6 +1687,7 @@
|
|||
"com_ui_unarchive": "Unarchive",
|
||||
"com_ui_unarchive_conversation": "Unarchive conversation",
|
||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||
"com_ui_unassigned": "Unassigned",
|
||||
"com_ui_unavailable": "Unavailable",
|
||||
"com_ui_unfavorite": "Remove from favorites",
|
||||
"com_ui_unknown": "Unknown",
|
||||
|
|
@ -1743,6 +1777,7 @@
|
|||
"com_ui_yes": "Yes",
|
||||
"com_ui_you": "You",
|
||||
"com_ui_your_api_key": "Your API Key",
|
||||
"com_ui_your_projects": "Your projects",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_ui_zoom_in": "Zoom in",
|
||||
"com_ui_zoom_level": "Zoom level",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Spinner, useToastContext } from '@librechat/client';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
isNotFoundError,
|
||||
isTemporaryConversation,
|
||||
logger,
|
||||
clearMessagesCache,
|
||||
} from '~/utils';
|
||||
import {
|
||||
useAssistantListMap,
|
||||
|
|
@ -21,7 +23,12 @@ import {
|
|||
useNewConvo,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
|
||||
import {
|
||||
useGetConvoIdQuery,
|
||||
useGetStartupConfig,
|
||||
useGetEndpointsQuery,
|
||||
useProjectQuery,
|
||||
} from '~/data-provider';
|
||||
import { ToolCallsMapProvider } from '~/Providers';
|
||||
import ChatView from '~/components/Chat/ChatView';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
|
@ -29,9 +36,13 @@ import useAuthRedirect from './useAuthRedirect';
|
|||
import temporaryStore from '~/store/temporary';
|
||||
import store from '~/store';
|
||||
|
||||
const isValidChatProjectId = (projectId: string | null): projectId is string =>
|
||||
projectId != null && /^[a-f\d]{24}$/i.test(projectId);
|
||||
|
||||
export default function ChatRoute() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { isAuthenticated, user, roles } = useAuthRedirect();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const defaultTemporaryChat = useRecoilValue(temporaryStore.defaultTemporaryChat);
|
||||
const setIsTemporary = useRecoilCallback(
|
||||
|
|
@ -46,11 +57,24 @@ export default function ChatRoute() {
|
|||
const index = 0;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { conversationId = '' } = useParams();
|
||||
const projectIdParam = searchParams.get('projectId');
|
||||
const chatProjectId = isValidChatProjectId(projectIdParam) ? projectIdParam : null;
|
||||
useIdChangeEffect(conversationId);
|
||||
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
|
||||
const { newConversation } = useNewConvo();
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const projectQuery = useProjectQuery(chatProjectId, {
|
||||
enabled: isAuthenticated && Boolean(chatProjectId),
|
||||
retry: false,
|
||||
staleTime: 30000,
|
||||
cacheTime: 300000,
|
||||
});
|
||||
const verifiedChatProjectId = projectQuery.data?._id === chatProjectId ? chatProjectId : null;
|
||||
const projectTemplate = useMemo(
|
||||
() => (verifiedChatProjectId ? { chatProjectId: verifiedChatProjectId } : {}),
|
||||
[verifiedChatProjectId],
|
||||
);
|
||||
|
||||
const modelsQuery = useGetModelsQuery({
|
||||
enabled: isAuthenticated,
|
||||
|
|
@ -81,15 +105,27 @@ export default function ChatRoute() {
|
|||
useEffect(() => {
|
||||
// Wait for roles to load so hasAgentAccess has a definitive value in useNewConvo
|
||||
const rolesLoaded = roles?.USER != null;
|
||||
const isNewConvo = conversationId === Constants.NEW_CONVO;
|
||||
const isDraftNewConvo = conversation?.conversationId === Constants.NEW_CONVO;
|
||||
const draftProjectMismatch = verifiedChatProjectId
|
||||
? conversation?.chatProjectId !== verifiedChatProjectId
|
||||
: conversation?.chatProjectId != null;
|
||||
const newConvoNeedsInit =
|
||||
isNewConvo && (!conversation || (isDraftNewConvo && draftProjectMismatch));
|
||||
const shouldSetConvo =
|
||||
(startupConfig && rolesLoaded && !hasSetConversation.current && !modelsQuery.data?.initial) ??
|
||||
(startupConfig &&
|
||||
rolesLoaded &&
|
||||
(!hasSetConversation.current || newConvoNeedsInit) &&
|
||||
!modelsQuery.data?.initial) ??
|
||||
false;
|
||||
/* Early exit if startupConfig is not loaded and conversation is already set and only initial models have loaded */
|
||||
if (!shouldSetConvo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewConvo = conversationId === Constants.NEW_CONVO;
|
||||
if (isNewConvo && chatProjectId && projectQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getNewConvoPreset = () => {
|
||||
const result = getDefaultModelSpec(startupConfig);
|
||||
|
|
@ -98,7 +134,7 @@ export default function ChatRoute() {
|
|||
|
||||
const queryParams: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'prompt' && key !== 'q' && key !== 'submit') {
|
||||
if (key !== 'prompt' && key !== 'q' && key !== 'submit' && key !== 'projectId') {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
|
@ -114,9 +150,10 @@ export default function ChatRoute() {
|
|||
const preset = getNewConvoPreset();
|
||||
|
||||
logger.log('conversation', 'ChatRoute, new convo effect', conversation);
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
newConversation({
|
||||
modelsData: modelsQuery.data,
|
||||
template: conversation ? conversation : undefined,
|
||||
template: projectTemplate,
|
||||
...(preset ? { preset } : {}),
|
||||
});
|
||||
|
||||
|
|
@ -161,9 +198,10 @@ export default function ChatRoute() {
|
|||
const preset = getNewConvoPreset();
|
||||
|
||||
logger.log('conversation', 'ChatRoute new convo, assistants effect', conversation);
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
newConversation({
|
||||
modelsData: modelsQuery.data,
|
||||
template: conversation ? conversation : undefined,
|
||||
template: projectTemplate,
|
||||
...(preset ? { preset } : {}),
|
||||
});
|
||||
hasSetConversation.current = true;
|
||||
|
|
@ -189,6 +227,13 @@ export default function ChatRoute() {
|
|||
endpointsQuery.data,
|
||||
modelsQuery.data,
|
||||
assistantListMap,
|
||||
chatProjectId,
|
||||
projectQuery.data?._id,
|
||||
projectQuery.isLoading,
|
||||
projectTemplate,
|
||||
queryClient,
|
||||
conversation?.chatProjectId,
|
||||
conversation?.conversationId,
|
||||
]);
|
||||
|
||||
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
|
||||
|
|
@ -218,7 +263,7 @@ export default function ChatRoute() {
|
|||
|
||||
return (
|
||||
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
|
||||
<ChatView index={index} />
|
||||
<ChatView index={index} project={verifiedChatProjectId ? projectQuery.data : undefined} />
|
||||
</ToolCallsMapProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ const loadSkillsView = () =>
|
|||
Component: m.default,
|
||||
}));
|
||||
|
||||
const loadProjectsView = () =>
|
||||
import('~/components/Projects').then((m) => ({
|
||||
Component: m.ProjectsView,
|
||||
}));
|
||||
|
||||
const loadProjectWorkspace = () =>
|
||||
import('~/components/Projects').then((m) => ({
|
||||
Component: m.ProjectWorkspace,
|
||||
}));
|
||||
|
||||
const baseEl = document.querySelector('base');
|
||||
const baseHref = baseEl?.getAttribute('href') || '/';
|
||||
|
||||
|
|
@ -152,6 +162,14 @@ export const router = createBrowserRouter(
|
|||
path: 'skills/:skillId/edit',
|
||||
lazy: loadSkillsView,
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
lazy: loadProjectsView,
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId',
|
||||
lazy: loadProjectWorkspace,
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
element: (
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
|
|||
|
||||
if (shouldUpdateParams) {
|
||||
const newParams = createChatSearchParams(newValue);
|
||||
if (newValue.chatProjectId) {
|
||||
newParams.set('projectId', newValue.chatProjectId);
|
||||
}
|
||||
const searchParams = createSearchParams(newParams);
|
||||
const url = `${window.location.pathname}?${searchParams.toString()}`;
|
||||
window.history.pushState({}, '', url);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ const dateGroupsSet = new Set([
|
|||
|
||||
export const groupConversationsByDate = (
|
||||
conversations: Array<TConversation | null>,
|
||||
dateField: 'updatedAt' | 'createdAt' = 'updatedAt',
|
||||
): GroupedConversations => {
|
||||
if (!Array.isArray(conversations)) {
|
||||
return [];
|
||||
|
|
@ -93,8 +94,9 @@ export const groupConversationsByDate = (
|
|||
seenConversationIds.add(conversation.conversationId);
|
||||
|
||||
let date: Date;
|
||||
if (conversation.updatedAt) {
|
||||
date = parseISO(conversation.updatedAt);
|
||||
const dateValue = conversation[dateField] ?? conversation.updatedAt ?? conversation.createdAt;
|
||||
if (dateValue) {
|
||||
date = parseISO(dateValue);
|
||||
} else {
|
||||
date = now;
|
||||
}
|
||||
|
|
@ -131,7 +133,8 @@ export const groupConversationsByDate = (
|
|||
sortedGroups.forEach((conversations) => {
|
||||
conversations.sort(
|
||||
(a: TConversation, b: TConversation) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
new Date(b[dateField] ?? b.updatedAt ?? 0).getTime() -
|
||||
new Date(a[dateField] ?? a.updatedAt ?? 0).getTime(),
|
||||
);
|
||||
});
|
||||
return Array.from(sortedGroups, ([key, value]) => [key, value]);
|
||||
|
|
@ -142,6 +145,40 @@ export type ConversationCursorData = {
|
|||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
function getConversationQueryProjectId(queryKey: readonly unknown[]): string | undefined {
|
||||
const params = queryKey[1];
|
||||
if (!params || typeof params !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
return (params as { projectId?: string }).projectId;
|
||||
}
|
||||
|
||||
function conversationMatchesProjectQuery(
|
||||
queryKey: readonly unknown[],
|
||||
conversation: Pick<TConversation, 'chatProjectId'>,
|
||||
): boolean {
|
||||
const projectId = getConversationQueryProjectId(queryKey);
|
||||
if (!projectId) {
|
||||
return true;
|
||||
}
|
||||
if (projectId === 'unassigned') {
|
||||
return !conversation.chatProjectId;
|
||||
}
|
||||
return conversation.chatProjectId === projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the project id from the current URL's `?projectId` param — the source of
|
||||
* truth for a new chat's project scope (the conversation atom can lag behind it).
|
||||
*/
|
||||
export function getRouteChatProjectId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
||||
return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
|
||||
}
|
||||
|
||||
// === InfiniteData helpers for cursor-based convo queries ===
|
||||
|
||||
export function findConversationInInfinite(
|
||||
|
|
@ -208,6 +245,9 @@ export function addConversationToAllConversationsQueries(
|
|||
.findAll([QueryKeys.allConversations], { exact: false });
|
||||
|
||||
for (const query of queries) {
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, newConversation)) {
|
||||
continue;
|
||||
}
|
||||
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (old) => {
|
||||
if (
|
||||
!old ||
|
||||
|
|
@ -322,6 +362,9 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver
|
|||
.findAll([QueryKeys.allConversations], { exact: false });
|
||||
|
||||
for (const query of queries) {
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, newConvo)) {
|
||||
continue;
|
||||
}
|
||||
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (oldData) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
|
|
@ -381,6 +424,9 @@ export function upsertConvoInAllQueries(
|
|||
|
||||
const now = new Date().toISOString();
|
||||
if (pageIdx === -1) {
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, nextConvo)) {
|
||||
return oldData;
|
||||
}
|
||||
const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null };
|
||||
return {
|
||||
...oldData,
|
||||
|
|
@ -404,6 +450,10 @@ export function upsertConvoInAllQueries(
|
|||
updatedAt: nextConvo.updatedAt ?? (moveToTop ? now : found.updatedAt),
|
||||
};
|
||||
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
|
||||
return removeConvoFromInfinitePages(oldData, updated.conversationId ?? '');
|
||||
}
|
||||
|
||||
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
|
||||
return {
|
||||
...oldData,
|
||||
|
|
@ -480,6 +530,10 @@ export function updateConvoInAllQueries(
|
|||
? { ...updater(found), updatedAt: new Date().toISOString() }
|
||||
: updater(found);
|
||||
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
|
||||
return removeConvoFromInfinitePages(oldData, conversationId);
|
||||
}
|
||||
|
||||
// If not moving to top, or already at top of page 0, update in place
|
||||
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
|
||||
return {
|
||||
|
|
|
|||
110
e2e/specs/mock/projects.spec.ts
Normal file
110
e2e/specs/mock/projects.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { MOCK_ENDPOINTS, mockReply, selectMockEndpoint, sendMessage } from './helpers';
|
||||
|
||||
/**
|
||||
* Creates a project from the all-projects page and returns its id.
|
||||
* Project creation navigates to the project workspace (`/projects/:id`).
|
||||
*/
|
||||
async function createProject(page: Page, name: string): Promise<string> {
|
||||
await page.goto('/projects', { timeout: 10000 });
|
||||
await page.getByRole('button', { name: 'New project' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox', { name: 'Project name' }).fill(name);
|
||||
await dialog.getByRole('button', { name: 'Create project' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible();
|
||||
const projectId = new URL(page.url()).pathname.split('/projects/')[1];
|
||||
expect(projectId).toBeTruthy();
|
||||
return projectId;
|
||||
}
|
||||
|
||||
const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
|
||||
test.describe('chat projects', () => {
|
||||
test('creates a project and lists it on the all-projects page', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const name = uniqueName('E2E Project');
|
||||
|
||||
await createProject(page, name);
|
||||
|
||||
await page.goto('/projects', { timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('starts a project-scoped chat and files it under the project', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const name = uniqueName('E2E Project');
|
||||
const projectId = await createProject(page, name);
|
||||
|
||||
// The workspace exposes a composer entry to start a chat in the project.
|
||||
await expect(page.getByRole('button', { name: `New chat in ${name}` }).first()).toBeVisible();
|
||||
|
||||
// Open the project-scoped new-chat landing directly.
|
||||
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
|
||||
|
||||
// The interactive project chip is present, and the composer is scoped.
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message input' });
|
||||
await expect(input).toHaveAttribute('placeholder', new RegExp(name));
|
||||
|
||||
// Switch to a mock endpoint so the message streams without a real API key;
|
||||
// the project scope must be retained across the model switch.
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
|
||||
// Send the message; it streams a reply and the new chat opens at /c/:id.
|
||||
const response = await sendMessage(page, 'hello from a project');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
|
||||
|
||||
// Expand the project in the sidebar and confirm the chat is filed under it.
|
||||
const projectRow = page.getByRole('button', { name }).first();
|
||||
if ((await projectRow.getAttribute('aria-expanded')) !== 'true') {
|
||||
await projectRow.click();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('removes the project scope via the chip ×', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const name = uniqueName('E2E Project');
|
||||
const projectId = await createProject(page, name);
|
||||
|
||||
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from project' }).click();
|
||||
|
||||
// Chip disappears and the URL drops the project scope.
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeHidden();
|
||||
await expect(page).toHaveURL((url) => !url.searchParams.has('projectId'));
|
||||
});
|
||||
|
||||
test('switches the project via the chip combobox', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const nameA = uniqueName('E2E Project A');
|
||||
const nameB = uniqueName('E2E Project B');
|
||||
await createProject(page, nameA);
|
||||
const projectIdA = new URL(page.url()).pathname.split('/projects/')[1];
|
||||
await createProject(page, nameB);
|
||||
const projectIdB = new URL(page.url()).pathname.split('/projects/')[1];
|
||||
|
||||
await page.goto(`/c/new?projectId=${projectIdA}`, { timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
|
||||
// Open the combobox and pick the other project.
|
||||
await page.locator('#project-landing-select').click();
|
||||
await page.getByRole('option', { name: nameB }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`projectId=${projectIdB}`));
|
||||
await expect(page.getByRole('textbox', { name: 'Message input' })).toHaveAttribute(
|
||||
'placeholder',
|
||||
new RegExp(nameB),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,7 @@ export async function ensureRequiredCollectionsExist(db: mongo.Db): Promise<void
|
|||
'agents', // Agents collection
|
||||
'promptgroups', // Prompt groups collection
|
||||
'projects', // Projects collection
|
||||
'chatprojects', // Private chat organization projects
|
||||
];
|
||||
|
||||
logger.debug('Ensuring required collections exist for permission system');
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export * from './modelSpecs';
|
|||
export * from './agents';
|
||||
/* Prompts */
|
||||
export * from './prompts';
|
||||
/* Projects */
|
||||
export * from './projects';
|
||||
/* Skills */
|
||||
export * from './skills';
|
||||
/* Endpoints */
|
||||
|
|
|
|||
220
packages/api/src/projects/handlers.ts
Normal file
220
packages/api/src/projects/handlers.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
|
||||
|
||||
import type {
|
||||
ChatProjectMethods,
|
||||
ChatProjectSortBy,
|
||||
ChatProjectSortDirection,
|
||||
CreateChatProjectInput,
|
||||
UpdateChatProjectInput,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const PROJECT_NOT_FOUND = 'Project not found';
|
||||
const CONVERSATION_NOT_FOUND = 'Conversation not found';
|
||||
|
||||
const PROJECT_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
|
||||
|
||||
interface ProjectUser {
|
||||
id: string;
|
||||
_id?: {
|
||||
toString(): string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectRequest extends Request {
|
||||
user?: ProjectUser;
|
||||
}
|
||||
|
||||
type ProjectHandlerDependencies = Pick<
|
||||
ChatProjectMethods,
|
||||
| 'listChatProjects'
|
||||
| 'createChatProject'
|
||||
| 'getChatProject'
|
||||
| 'updateChatProject'
|
||||
| 'deleteChatProject'
|
||||
| 'assignConversationToProject'
|
||||
>;
|
||||
|
||||
const getUserId = (req: ProjectRequest): string => req.user?.id ?? req.user?._id?.toString() ?? '';
|
||||
|
||||
const queryString = (value: Request['query'][string]): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return queryString(value[0]);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeString = (value: string | null | undefined): string =>
|
||||
typeof value === 'string' ? value.trim() : '';
|
||||
|
||||
const normalizeLimit = (value: Request['query'][string]): number => {
|
||||
const limit = parseInt(queryString(value) ?? '', 10);
|
||||
if (!Number.isFinite(limit)) {
|
||||
return 25;
|
||||
}
|
||||
return Math.min(Math.max(limit, 1), 100);
|
||||
};
|
||||
|
||||
const normalizeSortBy = (value: Request['query'][string]): ChatProjectSortBy | undefined => {
|
||||
const sortBy = queryString(value);
|
||||
return PROJECT_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
|
||||
? (sortBy as ChatProjectSortBy)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const normalizeSortDirection = (
|
||||
value: Request['query'][string],
|
||||
): ChatProjectSortDirection | undefined => {
|
||||
const sortDirection = queryString(value);
|
||||
return sortDirection === 'asc' || sortDirection === 'desc' ? sortDirection : undefined;
|
||||
};
|
||||
|
||||
const createProjectInput = (req: ProjectRequest): CreateChatProjectInput | null => {
|
||||
const name = normalizeString(req.body?.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description: typeof req.body?.description === 'string' ? req.body.description : '',
|
||||
};
|
||||
};
|
||||
|
||||
export function createProjectHandlers(deps: ProjectHandlerDependencies) {
|
||||
async function listProjects(req: ProjectRequest, res: Response) {
|
||||
try {
|
||||
const result = await deps.listChatProjects(getUserId(req), {
|
||||
cursor: queryString(req.query.cursor),
|
||||
limit: normalizeLimit(req.query.limit),
|
||||
sortBy: normalizeSortBy(req.query.sortBy),
|
||||
sortDirection: normalizeSortDirection(req.query.sortDirection),
|
||||
search: queryString(req.query.search),
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('[projects] Error listing projects', error);
|
||||
return res.status(500).json({ error: 'Error listing projects' });
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(req: ProjectRequest, res: Response) {
|
||||
const input = createProjectInput(req);
|
||||
if (!input) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await deps.createChatProject(getUserId(req), input);
|
||||
return res.status(201).json(project);
|
||||
} catch (error) {
|
||||
logger.error('[projects] Error creating project', error);
|
||||
return res.status(500).json({ error: 'Error creating project' });
|
||||
}
|
||||
}
|
||||
|
||||
async function assignConversationToProject(req: ProjectRequest, res: Response) {
|
||||
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 deps.assignConversationToProject(
|
||||
getUserId(req),
|
||||
conversationId,
|
||||
projectId,
|
||||
);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
|
||||
}
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === PROJECT_NOT_FOUND) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
logger.error('[projects] Error assigning conversation to project', error);
|
||||
return res.status(500).json({ error: 'Error assigning conversation to project' });
|
||||
}
|
||||
}
|
||||
|
||||
async function getProject(req: ProjectRequest, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await deps.getChatProject(getUserId(req), projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
return res.status(200).json(project);
|
||||
} catch (error) {
|
||||
logger.error('[projects] Error getting project', error);
|
||||
return res.status(500).json({ error: 'Error getting project' });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(req: ProjectRequest, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
|
||||
const input: UpdateChatProjectInput = {};
|
||||
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 deps.updateChatProject(getUserId(req), projectId, input);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
return res.status(200).json(project);
|
||||
} catch (error) {
|
||||
logger.error('[projects] Error updating project', error);
|
||||
return res.status(500).json({ error: 'Error updating project' });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(req: ProjectRequest, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.deleteChatProject(getUserId(req), projectId);
|
||||
if (!result.deletedCount) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('[projects] Error deleting project', error);
|
||||
return res.status(500).json({ error: 'Error deleting project' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listProjects,
|
||||
createProject,
|
||||
assignConversationToProject,
|
||||
getProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
};
|
||||
}
|
||||
1
packages/api/src/projects/index.ts
Normal file
1
packages/api/src/projects/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './handlers';
|
||||
|
|
@ -24,6 +24,7 @@ interface ControlComboboxProps {
|
|||
disabled?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
selectId?: string;
|
||||
placement?: Ariakit.SelectStoreProps['placement'];
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
|
@ -45,6 +46,7 @@ function ControlCombobox({
|
|||
iconClassName,
|
||||
iconSide = 'left',
|
||||
selectId,
|
||||
placement,
|
||||
}: ControlComboboxProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -69,6 +71,7 @@ function ControlCombobox({
|
|||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
placement,
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@ export const forkConversation = () => `${conversationsRoot}/fork`;
|
|||
|
||||
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
|
||||
|
||||
export const projectsRoot = `${BASE_URL}/api/projects`;
|
||||
|
||||
export const projects = (params: q.ProjectListParams = {}) => {
|
||||
return `${projectsRoot}${buildQuery(params)}`;
|
||||
};
|
||||
|
||||
export const projectById = (id: string) => `${projectsRoot}/${encodeURIComponent(id)}`;
|
||||
|
||||
export const projectConversation = (conversationId: string) =>
|
||||
`${projectsRoot}/conversations/${encodeURIComponent(conversationId)}`;
|
||||
|
||||
export const search = (q: string, cursor?: string | null) =>
|
||||
`${BASE_URL}/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ function mergeBedrockAnthropicBetaHeaders(existing: unknown, generated: string[]
|
|||
export const bedrockInputSchema = s.tConversationSchema
|
||||
.pick({
|
||||
/* LibreChat params; optionType: 'conversation' */
|
||||
chatProjectId: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
resendFiles: true,
|
||||
|
|
@ -268,6 +269,7 @@ export type BedrockConverseInput = z.infer<typeof bedrockInputSchema>;
|
|||
export const bedrockInputParser = s.tConversationSchema
|
||||
.pick({
|
||||
/* LibreChat params; optionType: 'conversation' */
|
||||
chatProjectId: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
resendFiles: true,
|
||||
|
|
@ -297,6 +299,7 @@ export const bedrockInputParser = s.tConversationSchema
|
|||
.catchall(z.any())
|
||||
.transform((data) => {
|
||||
const knownKeys = [
|
||||
'chatProjectId',
|
||||
'modelLabel',
|
||||
'promptPrefix',
|
||||
'resendFiles',
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const excludedKeys = new Set([
|
|||
'files',
|
||||
'spec',
|
||||
'disableParams',
|
||||
'chatProjectId',
|
||||
]);
|
||||
|
||||
export enum SettingsViews {
|
||||
|
|
|
|||
|
|
@ -794,6 +794,34 @@ export function archiveConversation(
|
|||
return request.post(endpoints.archiveConversation(), { arg: payload });
|
||||
}
|
||||
|
||||
export function listProjects(params?: q.ProjectListParams): Promise<q.ProjectListResponse> {
|
||||
return request.get(endpoints.projects(params ?? {}));
|
||||
}
|
||||
|
||||
export function createProject(payload: t.TCreateChatProjectRequest): Promise<t.TChatProject> {
|
||||
return request.post(endpoints.projects(), payload);
|
||||
}
|
||||
|
||||
export function getProjectById(projectId: string): Promise<t.TChatProject> {
|
||||
return request.get(endpoints.projectById(projectId));
|
||||
}
|
||||
|
||||
export function updateProject(payload: t.TUpdateChatProjectRequest): Promise<t.TChatProject> {
|
||||
const { projectId, ...data } = payload;
|
||||
return request.patch(endpoints.projectById(projectId), data);
|
||||
}
|
||||
|
||||
export function deleteProject(projectId: string): Promise<t.TDeleteChatProjectResponse> {
|
||||
return request.delete(endpoints.projectById(projectId));
|
||||
}
|
||||
|
||||
export function assignConversationToProject(
|
||||
payload: t.TAssignConversationToProjectRequest,
|
||||
): Promise<t.TAssignConversationToProjectResponse> {
|
||||
const { conversationId, projectId } = payload;
|
||||
return request.put(endpoints.projectConversation(conversationId), { projectId });
|
||||
}
|
||||
|
||||
export function genTitle(payload: m.TGenTitleRequest): Promise<m.TGenTitleResponse> {
|
||||
return request.get(endpoints.genTitle(payload.conversationId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export enum QueryKeys {
|
|||
promptGroups = 'promptGroups',
|
||||
allPromptGroups = 'allPromptGroups',
|
||||
promptGroup = 'promptGroup',
|
||||
projects = 'projects',
|
||||
project = 'project',
|
||||
projectConversations = 'projectConversations',
|
||||
categories = 'categories',
|
||||
randomPrompts = 'randomPrompts',
|
||||
agentCategories = 'agentCategories',
|
||||
|
|
@ -112,6 +115,10 @@ export enum MutationKeys {
|
|||
enableTwoFactor = 'enableTwoFactor',
|
||||
verifyTwoFactor = 'verifyTwoFactor',
|
||||
updateMemoryPreferences = 'updateMemoryPreferences',
|
||||
createProject = 'createProject',
|
||||
updateProject = 'updateProject',
|
||||
deleteProject = 'deleteProject',
|
||||
assignConversationToProject = 'assignConversationToProject',
|
||||
/* Skill mutations from the original UI PR — tree/node operations are
|
||||
* phase 2 and currently stubbed in the data-service layer. */
|
||||
createSkillNode = 'createSkillNode',
|
||||
|
|
|
|||
|
|
@ -463,6 +463,14 @@ describe('googleSettings', () => {
|
|||
});
|
||||
expect(result.maxOutputTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves chat project membership metadata', () => {
|
||||
const result = compactGoogleSchema.parse({
|
||||
model: 'gemini-2.5-pro',
|
||||
chatProjectId: 'project-1',
|
||||
});
|
||||
expect(result.chatProjectId).toBe('project-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -859,6 +859,7 @@ export const tConversationSchema = z.object({
|
|||
examples: z.array(tExampleSchema).optional(),
|
||||
/* DB */
|
||||
tags: z.array(z.string()).optional(),
|
||||
chatProjectId: z.string().nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
/* Files */
|
||||
|
|
@ -914,6 +915,7 @@ export const tConversationSchema = z.object({
|
|||
export const tPresetSchema = tConversationSchema
|
||||
.omit({
|
||||
conversationId: true,
|
||||
chatProjectId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
title: true,
|
||||
|
|
@ -1094,6 +1096,7 @@ export const tConversationTagSchema = z.object({
|
|||
export type TConversationTag = z.infer<typeof tConversationTagSchema>;
|
||||
|
||||
export const googleBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
|
|
@ -1165,6 +1168,7 @@ export function removeNullishValues<T extends Record<string, unknown>>(
|
|||
}
|
||||
|
||||
const assistantBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
|
|
@ -1200,6 +1204,7 @@ export const assistantSchema = assistantBaseSchema
|
|||
}));
|
||||
|
||||
const compactAssistantBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
instructions: true,
|
||||
|
|
@ -1215,6 +1220,7 @@ export const compactAssistantSchema = compactAssistantBaseSchema
|
|||
.catch(() => ({}));
|
||||
|
||||
export const agentsBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
temperature: true,
|
||||
|
|
@ -1268,6 +1274,7 @@ export const agentsSchema = agentsBaseSchema
|
|||
}));
|
||||
|
||||
export const openAIBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
chatGptLabel: true,
|
||||
|
|
@ -1324,6 +1331,7 @@ export const compactGoogleSchema = googleBaseSchema
|
|||
.catch(() => ({}));
|
||||
|
||||
export const anthropicBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
model: true,
|
||||
modelLabel: true,
|
||||
promptPrefix: true,
|
||||
|
|
@ -1365,6 +1373,7 @@ export const tBannerSchema = z.object({
|
|||
export type TBanner = z.infer<typeof tBannerSchema>;
|
||||
|
||||
export const compactAgentsBaseSchema = tConversationSchema.pick({
|
||||
chatProjectId: true,
|
||||
spec: true,
|
||||
// model: true,
|
||||
iconURL: true,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export type TEndpointOption = Pick<
|
|||
| 'file_ids'
|
||||
// System field
|
||||
| 'system'
|
||||
| 'chatProjectId'
|
||||
// Google examples
|
||||
| 'examples'
|
||||
// Context
|
||||
|
|
@ -287,6 +288,43 @@ export type TUpdateConversationRequest = {
|
|||
|
||||
export type TUpdateConversationResponse = TConversation;
|
||||
|
||||
export type TChatProject = {
|
||||
_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
user?: string;
|
||||
conversationCount: number;
|
||||
lastConversationAt?: string | null;
|
||||
lastConversationId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TCreateChatProjectRequest = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type TUpdateChatProjectRequest = Partial<TCreateChatProjectRequest> & {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TDeleteChatProjectResponse = {
|
||||
deletedCount: number;
|
||||
modifiedCount: number;
|
||||
};
|
||||
|
||||
export type TAssignConversationToProjectRequest = {
|
||||
conversationId: string;
|
||||
projectId: string | null;
|
||||
};
|
||||
|
||||
export type TAssignConversationToProjectResponse = {
|
||||
conversation: TConversation;
|
||||
previousProjectId: string | null;
|
||||
projectId: string | null;
|
||||
};
|
||||
|
||||
export type TDeleteConversationRequest = {
|
||||
conversationId?: string;
|
||||
thread_id?: string;
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ export type ConversationListParams = {
|
|||
sortDirection?: 'asc' | 'desc';
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export type MinimalConversation = Pick<
|
||||
s.TConversation,
|
||||
'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user'
|
||||
'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user' | 'chatProjectId'
|
||||
>;
|
||||
|
||||
export type ConversationListResponse = {
|
||||
|
|
@ -37,6 +38,21 @@ export type ConversationUpdater = (
|
|||
conversation: s.TConversation,
|
||||
) => ConversationData;
|
||||
|
||||
export type ProjectListParams = {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'lastConversationAt';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type ProjectListResponse = {
|
||||
projects: t.TChatProject[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type ProjectData = InfiniteData<ProjectListResponse>;
|
||||
|
||||
/* Messages */
|
||||
export type MessagesListParams = {
|
||||
cursor?: string | null;
|
||||
|
|
|
|||
293
packages/data-schemas/src/methods/chatProject.spec.ts
Normal file
293
packages/data-schemas/src/methods/chatProject.spec.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createModels } from '~/models';
|
||||
import type { IChatProject, IConversation } from '~/types';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
let mongoServer: InstanceType<typeof MongoMemoryServer>;
|
||||
let ChatProject: mongoose.Model<IChatProject>;
|
||||
let Conversation: mongoose.Model<IConversation>;
|
||||
let methods: ChatProjectMethods;
|
||||
let modelsToCleanup: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
|
||||
const models = createModels(mongoose);
|
||||
modelsToCleanup = Object.keys(models);
|
||||
Object.assign(mongoose.models, models);
|
||||
|
||||
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
methods = createChatProjectMethods(mongoose);
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
|
||||
for (const modelName of modelsToCleanup) {
|
||||
if (mongoose.models[modelName]) {
|
||||
delete mongoose.models[modelName];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ChatProject.deleteMany({});
|
||||
await Conversation.deleteMany({});
|
||||
});
|
||||
|
||||
async function createConversation(user: string, conversationId: string, title: string) {
|
||||
return await Conversation.create({
|
||||
conversationId,
|
||||
title,
|
||||
user,
|
||||
endpoint: 'openAI',
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatProject methods', () => {
|
||||
const user = 'user-1';
|
||||
const otherUser = 'user-2';
|
||||
|
||||
it('creates, reads, updates, and lists private projects', async () => {
|
||||
const project = await methods.createChatProject(user, {
|
||||
name: 'Customer Alpha',
|
||||
description: 'Support work',
|
||||
});
|
||||
|
||||
expect(project.name).toBe('Customer Alpha');
|
||||
expect(project.conversationCount).toBe(0);
|
||||
|
||||
const readProject = await methods.getChatProject(user, project._id!.toString());
|
||||
expect(readProject?.description).toBe('Support work');
|
||||
|
||||
const updatedProject = await methods.updateChatProject(user, project._id!.toString(), {
|
||||
name: 'Customer Alpha Updated',
|
||||
});
|
||||
expect(updatedProject?.name).toBe('Customer Alpha Updated');
|
||||
|
||||
const list = await methods.listChatProjects(user, { sortBy: 'name', sortDirection: 'asc' });
|
||||
expect(list.projects).toHaveLength(1);
|
||||
expect(list.projects[0].name).toBe('Customer Alpha Updated');
|
||||
});
|
||||
|
||||
it('paginates projects deterministically when latest activity is null', async () => {
|
||||
const staleProject = await methods.createChatProject(user, { name: 'Stale' });
|
||||
await methods.createChatProject(user, { name: 'Quiet A' });
|
||||
await methods.createChatProject(user, { name: 'Quiet B' });
|
||||
const recentProject = await methods.createChatProject(user, { name: 'Recent' });
|
||||
|
||||
await ChatProject.updateOne(
|
||||
{ _id: staleProject._id },
|
||||
{ $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
|
||||
);
|
||||
await ChatProject.updateOne(
|
||||
{ _id: recentProject._id },
|
||||
{ $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
|
||||
);
|
||||
|
||||
const firstPage = await methods.listChatProjects(user, {
|
||||
sortBy: 'lastConversationAt',
|
||||
sortDirection: 'desc',
|
||||
limit: 3,
|
||||
});
|
||||
const secondPage = await methods.listChatProjects(user, {
|
||||
sortBy: 'lastConversationAt',
|
||||
sortDirection: 'desc',
|
||||
limit: 3,
|
||||
cursor: firstPage.nextCursor,
|
||||
});
|
||||
const names = [...firstPage.projects, ...secondPage.projects].map((project) => project.name);
|
||||
|
||||
expect(firstPage.projects[0].name).toBe('Recent');
|
||||
expect(firstPage.projects[1].name).toBe('Stale');
|
||||
expect(firstPage.nextCursor).toBeTruthy();
|
||||
expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
|
||||
expect(names).toEqual(expect.arrayContaining(['Recent', 'Stale', 'Quiet A', 'Quiet B']));
|
||||
expect(new Set(names).size).toBe(4);
|
||||
|
||||
const invalidCursor = Buffer.from(
|
||||
JSON.stringify({ primary: 'not-a-date', id: recentProject._id!.toString() }),
|
||||
).toString('base64');
|
||||
const invalidCursorPage = await methods.listChatProjects(user, {
|
||||
sortBy: 'lastConversationAt',
|
||||
sortDirection: 'desc',
|
||||
limit: 1,
|
||||
cursor: invalidCursor,
|
||||
});
|
||||
|
||||
expect(invalidCursorPage.projects[0].name).toBe('Recent');
|
||||
});
|
||||
|
||||
it('paginates chat-less projects when a page ends on the last dated project', async () => {
|
||||
const staleProject = await methods.createChatProject(user, { name: 'Stale' });
|
||||
await methods.createChatProject(user, { name: 'Quiet A' });
|
||||
await methods.createChatProject(user, { name: 'Quiet B' });
|
||||
const recentProject = await methods.createChatProject(user, { name: 'Recent' });
|
||||
|
||||
await ChatProject.updateOne(
|
||||
{ _id: staleProject._id },
|
||||
{ $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
|
||||
);
|
||||
await ChatProject.updateOne(
|
||||
{ _id: recentProject._id },
|
||||
{ $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
|
||||
);
|
||||
|
||||
// limit equals the number of dated projects, so the cursor lands on a dated
|
||||
// project; the null (chat-less) projects must still appear on the next page.
|
||||
const firstPage = await methods.listChatProjects(user, {
|
||||
sortBy: 'lastConversationAt',
|
||||
sortDirection: 'desc',
|
||||
limit: 2,
|
||||
});
|
||||
const secondPage = await methods.listChatProjects(user, {
|
||||
sortBy: 'lastConversationAt',
|
||||
sortDirection: 'desc',
|
||||
limit: 2,
|
||||
cursor: firstPage.nextCursor,
|
||||
});
|
||||
|
||||
expect(firstPage.projects.map((project) => project.name)).toEqual(['Recent', 'Stale']);
|
||||
expect(firstPage.nextCursor).toBeTruthy();
|
||||
expect(secondPage.projects.map((project) => project.name).sort()).toEqual([
|
||||
'Quiet A',
|
||||
'Quiet B',
|
||||
]);
|
||||
expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns many conversations to one project and updates stats', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Customer Alpha' });
|
||||
await createConversation(user, 'convo-1', 'First');
|
||||
await createConversation(user, 'convo-2', 'Second');
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
await methods.assignConversationToProject(user, 'convo-2', project._id!.toString());
|
||||
|
||||
const conversations = await Conversation.find({
|
||||
user,
|
||||
chatProjectId: project._id!.toString(),
|
||||
}).lean<IConversation[]>();
|
||||
const refreshedProject = await methods.getChatProject(user, project._id!.toString());
|
||||
|
||||
expect(conversations).toHaveLength(2);
|
||||
expect(refreshedProject?.conversationCount).toBe(2);
|
||||
expect(refreshedProject?.lastConversationId).toBeDefined();
|
||||
});
|
||||
|
||||
it('excludes retention-hidden conversations from project stats', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Visible Stats' });
|
||||
const chatProjectId = project._id!.toString();
|
||||
const visibleDate = new Date('2026-01-01T00:00:00.000Z');
|
||||
const hiddenDate = new Date('2026-02-01T00:00:00.000Z');
|
||||
|
||||
await Conversation.collection.insertMany([
|
||||
{
|
||||
conversationId: 'visible-convo',
|
||||
title: 'Visible',
|
||||
user,
|
||||
endpoint: 'openAI',
|
||||
chatProjectId,
|
||||
isTemporary: false,
|
||||
expiredAt: null,
|
||||
createdAt: visibleDate,
|
||||
updatedAt: visibleDate,
|
||||
},
|
||||
{
|
||||
conversationId: 'temporary-convo',
|
||||
title: 'Temporary',
|
||||
user,
|
||||
endpoint: 'openAI',
|
||||
chatProjectId,
|
||||
isTemporary: true,
|
||||
expiredAt: new Date('2027-03-01T00:00:00.000Z'),
|
||||
createdAt: hiddenDate,
|
||||
updatedAt: hiddenDate,
|
||||
},
|
||||
{
|
||||
conversationId: 'expired-convo',
|
||||
title: 'Expired',
|
||||
user,
|
||||
endpoint: 'openAI',
|
||||
chatProjectId,
|
||||
isTemporary: false,
|
||||
expiredAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
createdAt: hiddenDate,
|
||||
updatedAt: hiddenDate,
|
||||
},
|
||||
]);
|
||||
|
||||
const refreshedProject = await methods.refreshChatProjectStats(user, chatProjectId);
|
||||
|
||||
expect(refreshedProject?.conversationCount).toBe(1);
|
||||
expect(refreshedProject?.lastConversationId).toBe('visible-convo');
|
||||
expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(visibleDate.toISOString());
|
||||
});
|
||||
|
||||
it('enforces one project per chat when moving conversations', async () => {
|
||||
const firstProject = await methods.createChatProject(user, { name: 'First' });
|
||||
const secondProject = await methods.createChatProject(user, { name: 'Second' });
|
||||
await createConversation(user, 'convo-1', 'First');
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', firstProject._id!.toString());
|
||||
await methods.assignConversationToProject(user, 'convo-1', secondProject._id!.toString());
|
||||
|
||||
const movedConversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
const refreshedFirst = await methods.getChatProject(user, firstProject._id!.toString());
|
||||
const refreshedSecond = await methods.getChatProject(user, secondProject._id!.toString());
|
||||
|
||||
expect(movedConversation?.chatProjectId).toBe(secondProject._id!.toString());
|
||||
expect(refreshedFirst?.conversationCount).toBe(0);
|
||||
expect(refreshedSecond?.conversationCount).toBe(1);
|
||||
});
|
||||
|
||||
it('deleting a project unassigns chats instead of deleting them', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Delete me' });
|
||||
await createConversation(user, 'convo-1', 'First');
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
|
||||
const result = await methods.deleteChatProject(user, project._id!.toString());
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
expect(result.modifiedCount).toBe(1);
|
||||
expect(conversation).not.toBeNull();
|
||||
expect(conversation?.chatProjectId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isolates projects and assignments by user', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Mine' });
|
||||
await createConversation(otherUser, 'convo-1', 'Theirs');
|
||||
|
||||
const otherRead = await methods.getChatProject(otherUser, project._id!.toString());
|
||||
const assignment = await methods.assignConversationToProject(
|
||||
user,
|
||||
'convo-1',
|
||||
project._id!.toString(),
|
||||
);
|
||||
const deleteResult = await methods.deleteChatProject(otherUser, project._id!.toString());
|
||||
|
||||
expect(otherRead).toBeNull();
|
||||
expect(assignment).toBeNull();
|
||||
expect(deleteResult.deletedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
463
packages/data-schemas/src/methods/chatProject.ts
Normal file
463
packages/data-schemas/src/methods/chatProject.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
|
||||
import logger from '~/config/winston';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import { buildRetentionVisibilityFilter } from '~/utils/retention';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
|
||||
|
||||
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
export type ChatProjectSortDirection = 'asc' | 'desc';
|
||||
|
||||
export type CreateChatProjectInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateChatProjectInput = Partial<CreateChatProjectInput>;
|
||||
|
||||
export type ListChatProjectsOptions = {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
sortBy?: ChatProjectSortBy;
|
||||
sortDirection?: ChatProjectSortDirection;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type ListChatProjectsResult = {
|
||||
projects: IChatProject[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type DeleteChatProjectResult = {
|
||||
deletedCount: number;
|
||||
modifiedCount: number;
|
||||
};
|
||||
|
||||
export type AssignConversationToProjectResult = {
|
||||
conversation: IConversation;
|
||||
previousProjectId: string | null;
|
||||
projectId: string | null;
|
||||
};
|
||||
|
||||
export interface ChatProjectMethods {
|
||||
createChatProject(user: string, input: CreateChatProjectInput): Promise<IChatProject>;
|
||||
getChatProject(user: string, projectId: string): Promise<IChatProject | null>;
|
||||
listChatProjects(
|
||||
user: string,
|
||||
options?: ListChatProjectsOptions,
|
||||
): Promise<ListChatProjectsResult>;
|
||||
updateChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
input: UpdateChatProjectInput,
|
||||
): Promise<IChatProject | null>;
|
||||
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
|
||||
assignConversationToProject(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
): Promise<AssignConversationToProjectResult | null>;
|
||||
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
|
||||
}
|
||||
|
||||
type ProjectCursor = {
|
||||
primary: string | null;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ProjectLean = IChatProject & { _id: Types.ObjectId };
|
||||
|
||||
const VALID_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
|
||||
|
||||
function normalizeSortBy(sortBy?: string): ChatProjectSortBy {
|
||||
return VALID_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
|
||||
? (sortBy as ChatProjectSortBy)
|
||||
: 'lastConversationAt';
|
||||
}
|
||||
|
||||
function normalizeSortDirection(sortDirection?: string): ChatProjectSortDirection {
|
||||
return sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number): number {
|
||||
if (!Number.isFinite(limit) || !limit) {
|
||||
return 25;
|
||||
}
|
||||
return Math.min(Math.max(Math.floor(limit), 1), 100);
|
||||
}
|
||||
|
||||
function sanitizeProjectInput(input: CreateChatProjectInput): CreateChatProjectInput {
|
||||
return {
|
||||
name: input.name.trim().slice(0, 100),
|
||||
description: input.description?.trim().slice(0, 1000) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseCursor(cursor?: string | null): ProjectCursor | null {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()) as ProjectCursor;
|
||||
if (!decoded.id || !isValidObjectIdString(decoded.id)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
primary: decoded.primary ?? null,
|
||||
id: decoded.id,
|
||||
};
|
||||
} catch {
|
||||
logger.warn('[listChatProjects] Invalid cursor format, starting from beginning');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(project: ProjectLean, sortBy: ChatProjectSortBy): string {
|
||||
let primary: string | null = null;
|
||||
if (sortBy === 'name') {
|
||||
primary = project.name;
|
||||
} else {
|
||||
const date = project[sortBy];
|
||||
primary = date instanceof Date ? date.toISOString() : null;
|
||||
}
|
||||
return Buffer.from(JSON.stringify({ primary, id: project._id.toString() })).toString('base64');
|
||||
}
|
||||
|
||||
function cursorPrimaryValue(
|
||||
primary: string | null,
|
||||
sortBy: ChatProjectSortBy,
|
||||
): string | Date | null | undefined {
|
||||
if (primary == null) {
|
||||
return sortBy === 'lastConversationAt' ? null : undefined;
|
||||
}
|
||||
|
||||
if (sortBy === 'name') {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const date = new Date(primary);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function createCursorFilter(
|
||||
mongoose: typeof import('mongoose'),
|
||||
cursor: ProjectCursor | null,
|
||||
sortBy: ChatProjectSortBy,
|
||||
sortDirection: ChatProjectSortDirection,
|
||||
): FilterQuery<IChatProjectDocument> | null {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const op = sortDirection === 'asc' ? '$gt' : '$lt';
|
||||
const id = new mongoose.Types.ObjectId(cursor.id);
|
||||
const primary = cursorPrimaryValue(cursor.primary, sortBy);
|
||||
|
||||
if (primary === undefined) {
|
||||
logger.warn('[listChatProjects] Invalid cursor primary value, starting from beginning');
|
||||
return null;
|
||||
}
|
||||
|
||||
const branches: FilterQuery<IChatProjectDocument>[] = [
|
||||
{ [sortBy]: { [op]: primary } },
|
||||
{ [sortBy]: primary, _id: { [op]: id } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Projects with no conversations have `lastConversationAt: null`, which sorts
|
||||
* after all dated projects in descending order. A `$lt: <date>` predicate does
|
||||
* not match null, so once the cursor moves past the dated projects we must
|
||||
* include the null bucket explicitly, otherwise empty projects never paginate.
|
||||
*/
|
||||
if (sortBy === 'lastConversationAt' && sortDirection === 'desc' && primary instanceof Date) {
|
||||
branches.push({ lastConversationAt: null } as FilterQuery<IChatProjectDocument>);
|
||||
}
|
||||
|
||||
return { $or: branches } as FilterQuery<IChatProjectDocument>;
|
||||
}
|
||||
|
||||
function visibleProjectConversationFilter(
|
||||
user: string,
|
||||
projectId: string,
|
||||
): FilterQuery<IConversation> {
|
||||
return {
|
||||
$and: [
|
||||
{ user, chatProjectId: projectId },
|
||||
{ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] },
|
||||
buildRetentionVisibilityFilter<IConversation>(),
|
||||
],
|
||||
} as FilterQuery<IConversation>;
|
||||
}
|
||||
|
||||
export async function refreshChatProjectStatsForUser(
|
||||
mongoose: typeof import('mongoose'),
|
||||
user: string,
|
||||
projectId: string,
|
||||
): Promise<IChatProject | null> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
|
||||
const conversationFilter = visibleProjectConversationFilter(user, projectId);
|
||||
|
||||
const [conversationCount, latestConversation] = await Promise.all([
|
||||
Conversation.countDocuments(conversationFilter),
|
||||
Conversation.findOne(conversationFilter)
|
||||
.select('conversationId updatedAt createdAt')
|
||||
.sort({ updatedAt: -1, _id: -1 })
|
||||
.lean<IConversation>(),
|
||||
]);
|
||||
|
||||
return await ChatProject.findOneAndUpdate(
|
||||
projectFilter,
|
||||
{
|
||||
$set: {
|
||||
conversationCount,
|
||||
lastConversationAt: latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
|
||||
lastConversationId: latestConversation?.conversationId ?? null,
|
||||
},
|
||||
},
|
||||
{ new: true },
|
||||
).lean<IChatProject>();
|
||||
}
|
||||
|
||||
export async function updateChatProjectLastConversationForUser(
|
||||
mongoose: typeof import('mongoose'),
|
||||
user: string,
|
||||
projectId: string,
|
||||
conversation: Pick<IConversation, 'conversationId' | 'createdAt' | 'updatedAt'>,
|
||||
incrementCount = false,
|
||||
): Promise<void> {
|
||||
if (!isValidObjectIdString(projectId) || !conversation.conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastConversationAt = conversation.updatedAt ?? conversation.createdAt ?? new Date();
|
||||
const update: Record<string, unknown> = {
|
||||
$set: {
|
||||
lastConversationAt,
|
||||
lastConversationId: conversation.conversationId,
|
||||
},
|
||||
};
|
||||
if (incrementCount) {
|
||||
update.$inc = { conversationCount: 1 };
|
||||
}
|
||||
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
|
||||
}
|
||||
|
||||
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
|
||||
async function createChatProject(
|
||||
user: string,
|
||||
input: CreateChatProjectInput,
|
||||
): Promise<IChatProject> {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const sanitized = sanitizeProjectInput(input);
|
||||
if (!sanitized.name) {
|
||||
throw new Error('Project name is required');
|
||||
}
|
||||
|
||||
const project = await ChatProject.create({
|
||||
...sanitized,
|
||||
user,
|
||||
conversationCount: 0,
|
||||
lastConversationAt: null,
|
||||
lastConversationId: null,
|
||||
});
|
||||
return project.toObject() as IChatProject;
|
||||
}
|
||||
|
||||
async function getChatProject(user: string, projectId: string): Promise<IChatProject | null> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
return await ChatProject.findOne({
|
||||
_id: new mongoose.Types.ObjectId(projectId),
|
||||
user,
|
||||
}).lean<IChatProject>();
|
||||
}
|
||||
|
||||
async function listChatProjects(
|
||||
user: string,
|
||||
options: ListChatProjectsOptions = {},
|
||||
): Promise<ListChatProjectsResult> {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const sortBy = normalizeSortBy(options.sortBy);
|
||||
const sortDirection = normalizeSortDirection(options.sortDirection);
|
||||
const sortOrder: SortOrder = sortDirection === 'asc' ? 1 : -1;
|
||||
const filters: FilterQuery<IChatProjectDocument>[] = [{ user }];
|
||||
|
||||
if (options.search?.trim()) {
|
||||
filters.push({ name: { $regex: escapeRegExp(options.search.trim()), $options: 'i' } });
|
||||
}
|
||||
|
||||
const cursorFilter = createCursorFilter(
|
||||
mongoose,
|
||||
parseCursor(options.cursor),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
if (cursorFilter) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
|
||||
const query =
|
||||
filters.length === 1 ? filters[0] : ({ $and: filters } as FilterQuery<IChatProjectDocument>);
|
||||
const projects = await ChatProject.find(query)
|
||||
.sort({ [sortBy]: sortOrder, _id: sortOrder })
|
||||
.limit(limit + 1)
|
||||
.lean<ProjectLean[]>();
|
||||
|
||||
let nextCursor: string | null = null;
|
||||
if (projects.length > limit) {
|
||||
projects.pop();
|
||||
const lastProject = projects[projects.length - 1];
|
||||
if (lastProject) {
|
||||
nextCursor = encodeCursor(lastProject, sortBy);
|
||||
}
|
||||
}
|
||||
|
||||
return { projects, nextCursor };
|
||||
}
|
||||
|
||||
async function updateChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
input: UpdateChatProjectInput,
|
||||
): Promise<IChatProject | null> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const update: Partial<Pick<IChatProject, 'name' | 'description'>> = {};
|
||||
if (typeof input.name === 'string') {
|
||||
const name = input.name.trim().slice(0, 100);
|
||||
if (!name) {
|
||||
throw new Error('Project name is required');
|
||||
}
|
||||
update.name = name;
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
update.description = input.description?.trim().slice(0, 1000) ?? '';
|
||||
}
|
||||
|
||||
return await ChatProject.findOneAndUpdate(
|
||||
{ _id: new mongoose.Types.ObjectId(projectId), user },
|
||||
{ $set: update },
|
||||
{ new: true, runValidators: true },
|
||||
).lean<IChatProject>();
|
||||
}
|
||||
|
||||
async function deleteChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
): Promise<DeleteChatProjectResult> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
}
|
||||
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
|
||||
const project = await ChatProject.findOne(projectFilter).select('_id').lean<IChatProject>();
|
||||
if (!project) {
|
||||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
}
|
||||
|
||||
const [conversationResult, deleteResult] = await Promise.all([
|
||||
Conversation.updateMany(
|
||||
{ user, chatProjectId: projectId },
|
||||
{ $unset: { chatProjectId: '' } },
|
||||
),
|
||||
ChatProject.deleteOne(projectFilter),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedCount: deleteResult.deletedCount ?? 0,
|
||||
modifiedCount: conversationResult.modifiedCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function assignConversationToProject(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
): Promise<AssignConversationToProjectResult | null> {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const conversation = await Conversation.findOne({ user, conversationId }).lean<IConversation>();
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedProjectId = projectId || null;
|
||||
if (normalizedProjectId) {
|
||||
if (!isValidObjectIdString(normalizedProjectId)) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const project = await ChatProject.findOne({
|
||||
_id: new mongoose.Types.ObjectId(normalizedProjectId),
|
||||
user,
|
||||
})
|
||||
.select('_id')
|
||||
.lean<IChatProject>();
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
}
|
||||
|
||||
const previousProjectId = conversation.chatProjectId ?? null;
|
||||
const update =
|
||||
normalizedProjectId == null
|
||||
? { $unset: { chatProjectId: '' } }
|
||||
: { $set: { chatProjectId: normalizedProjectId } };
|
||||
const updatedConversation = await Conversation.findOneAndUpdate(
|
||||
{ user, conversationId },
|
||||
update,
|
||||
{ new: true },
|
||||
).lean<IConversation>();
|
||||
|
||||
if (!updatedConversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectIds = new Set(
|
||||
[previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
await Promise.all(
|
||||
[...projectIds].map((id) => refreshChatProjectStatsForUser(mongoose, user, id)),
|
||||
);
|
||||
|
||||
return {
|
||||
conversation: updatedConversation,
|
||||
previousProjectId,
|
||||
projectId: normalizedProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshChatProjectStats(
|
||||
user: string,
|
||||
projectId: string,
|
||||
): Promise<IChatProject | null> {
|
||||
return await refreshChatProjectStatsForUser(mongoose, user, projectId);
|
||||
}
|
||||
|
||||
return {
|
||||
createChatProject,
|
||||
getChatProject,
|
||||
listChatProjects,
|
||||
updateChatProject,
|
||||
deleteChatProject,
|
||||
assignConversationToProject,
|
||||
refreshChatProjectStats,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
|
||||
import type { IConversation } from '../types';
|
||||
import type { IChatProject, IConversation } from '../types';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { ConversationMethods, createConversationMethods } from './conversation';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
|
|
@ -16,6 +16,7 @@ jest.mock('~/config/winston', () => ({
|
|||
|
||||
let mongoServer: InstanceType<typeof MongoMemoryServer>;
|
||||
let Conversation: mongoose.Model<IConversation>;
|
||||
let ChatProject: mongoose.Model<IChatProject>;
|
||||
let modelsToCleanup: string[] = [];
|
||||
|
||||
// Mock message methods (same as original test mocking ./Message)
|
||||
|
|
@ -32,6 +33,7 @@ beforeAll(async () => {
|
|||
modelsToCleanup = Object.keys(models);
|
||||
Object.assign(mongoose.models, models);
|
||||
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
|
||||
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
|
||||
|
||||
|
|
@ -91,6 +93,7 @@ describe('Conversation Operations', () => {
|
|||
beforeEach(async () => {
|
||||
// Clear database
|
||||
await Conversation.deleteMany({});
|
||||
await ChatProject.deleteMany({});
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -153,6 +156,155 @@ describe('Conversation Operations', () => {
|
|||
expect(result?.conversationId).toBe(newConversationId);
|
||||
});
|
||||
|
||||
it('refreshes project stats when archiving a project conversation', async () => {
|
||||
const project = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Project Stats',
|
||||
conversationCount: 0,
|
||||
lastConversationAt: null,
|
||||
lastConversationId: null,
|
||||
});
|
||||
const firstConversationId = uuidv4();
|
||||
const secondConversationId = uuidv4();
|
||||
const chatProjectId = project._id!.toString();
|
||||
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId: firstConversationId,
|
||||
title: 'First',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId,
|
||||
});
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId: secondConversationId,
|
||||
title: 'Second',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId,
|
||||
});
|
||||
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId: secondConversationId,
|
||||
isArchived: true,
|
||||
});
|
||||
|
||||
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
|
||||
expect(refreshedProject?.conversationCount).toBe(1);
|
||||
expect(refreshedProject?.lastConversationId).toBe(firstConversationId);
|
||||
});
|
||||
|
||||
it('bulkSaveConvos keeps owned project ids and strips orphan ones', async () => {
|
||||
const project = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Bulk Project',
|
||||
conversationCount: 0,
|
||||
lastConversationAt: null,
|
||||
lastConversationId: null,
|
||||
});
|
||||
const ownedId = project._id!.toString();
|
||||
const orphanId = new mongoose.Types.ObjectId().toString();
|
||||
const ownedConvoId = uuidv4();
|
||||
const orphanConvoId = uuidv4();
|
||||
|
||||
await methods.bulkSaveConvos([
|
||||
{
|
||||
conversationId: ownedConvoId,
|
||||
user: mockCtx.userId,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: ownedId,
|
||||
},
|
||||
{
|
||||
conversationId: orphanConvoId,
|
||||
user: mockCtx.userId,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: orphanId,
|
||||
},
|
||||
]);
|
||||
|
||||
const ownedConvo = await Conversation.findOne({
|
||||
conversationId: ownedConvoId,
|
||||
}).lean<IConversation>();
|
||||
const orphanConvo = await Conversation.findOne({
|
||||
conversationId: orphanConvoId,
|
||||
}).lean<IConversation>();
|
||||
|
||||
expect(ownedConvo?.chatProjectId).toBe(ownedId);
|
||||
expect(orphanConvo?.chatProjectId == null).toBe(true);
|
||||
|
||||
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
|
||||
expect(refreshedProject?.conversationCount).toBe(1);
|
||||
});
|
||||
|
||||
it('refreshes both projects when a save moves a conversation between them', async () => {
|
||||
const projectA = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Project A',
|
||||
conversationCount: 0,
|
||||
});
|
||||
const projectB = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Project B',
|
||||
conversationCount: 0,
|
||||
});
|
||||
const conversationId = uuidv4();
|
||||
const projectAId = projectA._id!.toString();
|
||||
const projectBId = projectB._id!.toString();
|
||||
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId,
|
||||
title: 'Moving',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: projectAId,
|
||||
});
|
||||
// A stale tab re-submits with the new project id, moving the chat A -> B.
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: projectBId,
|
||||
});
|
||||
|
||||
const refreshedA = await ChatProject.findById(projectA._id).lean<IChatProject>();
|
||||
const refreshedB = await ChatProject.findById(projectB._id).lean<IChatProject>();
|
||||
expect(refreshedA?.conversationCount).toBe(0);
|
||||
expect(refreshedA?.lastConversationId == null).toBe(true);
|
||||
expect(refreshedB?.conversationCount).toBe(1);
|
||||
expect(refreshedB?.lastConversationId).toBe(conversationId);
|
||||
});
|
||||
|
||||
it('bulkSaveConvos refreshes the project a conversation leaves', async () => {
|
||||
const projectA = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Bulk Project A',
|
||||
conversationCount: 0,
|
||||
});
|
||||
const projectB = await ChatProject.create({
|
||||
user: mockCtx.userId,
|
||||
name: 'Bulk Project B',
|
||||
conversationCount: 0,
|
||||
});
|
||||
const conversationId = uuidv4();
|
||||
const projectAId = projectA._id!.toString();
|
||||
const projectBId = projectB._id!.toString();
|
||||
|
||||
await saveConvo(mockCtx, {
|
||||
conversationId,
|
||||
title: 'Bulk Moving',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: projectAId,
|
||||
});
|
||||
await methods.bulkSaveConvos([
|
||||
{
|
||||
conversationId,
|
||||
user: mockCtx.userId,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: projectBId,
|
||||
},
|
||||
]);
|
||||
|
||||
const refreshedA = await ChatProject.findById(projectA._id).lean<IChatProject>();
|
||||
const refreshedB = await ChatProject.findById(projectB._id).lean<IChatProject>();
|
||||
expect(refreshedA?.conversationCount).toBe(0);
|
||||
expect(refreshedB?.conversationCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should not create a conversation when noUpsert is true and conversation does not exist', async () => {
|
||||
const nonExistentId = uuidv4();
|
||||
const result = await saveConvo(
|
||||
|
|
@ -1170,5 +1322,34 @@ describe('Conversation Operations', () => {
|
|||
expect(doc?.title).toBe('Updated');
|
||||
expect(doc?.tenantId).toBe('real-tenant');
|
||||
});
|
||||
|
||||
it('bulkSaveConvos refreshes stats for cloned project conversations', async () => {
|
||||
const project = await ChatProject.create({
|
||||
user: 'user123',
|
||||
name: 'Bulk Project',
|
||||
conversationCount: 0,
|
||||
lastConversationAt: null,
|
||||
lastConversationId: null,
|
||||
});
|
||||
const conversationId = uuidv4();
|
||||
const createdAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
await methods.bulkSaveConvos([
|
||||
{
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
title: 'Cloned Project Chat',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatProjectId: project._id!.toString(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
|
||||
expect(refreshedProject?.conversationCount).toBe(1);
|
||||
expect(refreshedProject?.lastConversationId).toBe(conversationId);
|
||||
expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(createdAt.toISOString());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import type { FilterQuery, Model, SortOrder } from 'mongoose';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import logger from '~/config/winston';
|
||||
import type { AppConfig, IConversation } from '~/types';
|
||||
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
|
||||
import {
|
||||
refreshChatProjectStatsForUser,
|
||||
updateChatProjectLastConversationForUser,
|
||||
} from './chatProject';
|
||||
import type { MessageMethods } from './message';
|
||||
import type { DeleteResult } from 'mongoose';
|
||||
|
||||
|
|
@ -36,6 +41,7 @@ export interface ConversationMethods {
|
|||
search?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: string;
|
||||
projectId?: string;
|
||||
},
|
||||
): Promise<{ conversations: IConversation[]; nextCursor: string | null }>;
|
||||
getConvosQueried(
|
||||
|
|
@ -208,6 +214,38 @@ export function createConversationMethods(
|
|||
|
||||
const messages = await getMessages({ conversationId, user: userId }, '_id');
|
||||
const update: Record<string, unknown> = { ...convo, messages, user: userId };
|
||||
const unsetFields: Record<string, number> = { ...(metadata?.unsetFields ?? {}) };
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(update, 'chatProjectId') && update.chatProjectId) {
|
||||
const chatProjectId = typeof update.chatProjectId === 'string' ? update.chatProjectId : '';
|
||||
let isValidChatProject = isValidObjectIdString(chatProjectId);
|
||||
|
||||
if (isValidChatProject) {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const project = await ChatProject.exists({
|
||||
_id: new mongoose.Types.ObjectId(chatProjectId),
|
||||
user: userId,
|
||||
});
|
||||
isValidChatProject = project != null;
|
||||
}
|
||||
|
||||
if (!isValidChatProject) {
|
||||
delete update.chatProjectId;
|
||||
unsetFields.chatProjectId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const mayChangeProjectMembership =
|
||||
Object.prototype.hasOwnProperty.call(update, 'chatProjectId') ||
|
||||
Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId');
|
||||
let previousChatProjectId: string | null = null;
|
||||
if (mayChangeProjectMembership) {
|
||||
const existing = await Conversation.findOne(
|
||||
{ conversationId, user: userId },
|
||||
'chatProjectId',
|
||||
).lean<{ chatProjectId?: string | null } | null>();
|
||||
previousChatProjectId = existing?.chatProjectId ?? null;
|
||||
}
|
||||
|
||||
if (newConversationId) {
|
||||
update.conversationId = newConversationId;
|
||||
|
|
@ -248,22 +286,35 @@ export function createConversationMethods(
|
|||
}
|
||||
|
||||
const updateOperation: Record<string, unknown> = { $set: update };
|
||||
if (metadata?.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
|
||||
updateOperation.$unset = metadata.unsetFields;
|
||||
if (Object.keys(unsetFields).length > 0) {
|
||||
updateOperation.$unset = unsetFields;
|
||||
}
|
||||
if (createdAtOnInsert) {
|
||||
updateOperation.$setOnInsert = { createdAt: createdAtOnInsert };
|
||||
}
|
||||
|
||||
const conversation = await Conversation.findOneAndUpdate(
|
||||
const conversationResult = (await Conversation.findOneAndUpdate(
|
||||
{ conversationId, user: userId },
|
||||
updateOperation,
|
||||
{
|
||||
new: true,
|
||||
upsert: metadata?.noUpsert !== true,
|
||||
includeResultMetadata: true,
|
||||
...(createdAtOnInsert ? { timestamps: false } : {}),
|
||||
},
|
||||
);
|
||||
)) as unknown as {
|
||||
value:
|
||||
| (IConversation & {
|
||||
_id: unknown;
|
||||
$isDefault: (path: string) => boolean;
|
||||
toObject: () => IConversation;
|
||||
})
|
||||
| null;
|
||||
lastErrorObject?: {
|
||||
updatedExisting?: boolean;
|
||||
};
|
||||
};
|
||||
const conversation = conversationResult.value;
|
||||
|
||||
if (!conversation) {
|
||||
logger.debug('[saveConvo] Conversation not found, skipping update');
|
||||
|
|
@ -283,6 +334,60 @@ export function createConversationMethods(
|
|||
conversation.isTemporary = false;
|
||||
}
|
||||
|
||||
const newChatProjectId = conversation.chatProjectId ?? null;
|
||||
const projectMembershipChanged = previousChatProjectId !== newChatProjectId;
|
||||
|
||||
/**
|
||||
* A chat that moved between projects (e.g. a stale tab re-submitting an
|
||||
* older project id) must fully recompute the stats of the project it left;
|
||||
* the incremental path only ever touches the project it now belongs to.
|
||||
*/
|
||||
if (projectMembershipChanged && previousChatProjectId) {
|
||||
await refreshChatProjectStatsForUser(mongoose, userId, previousChatProjectId);
|
||||
}
|
||||
|
||||
if (conversation.chatProjectId) {
|
||||
const isRetentionVisibilityUpdate =
|
||||
typeof update.isTemporary === 'boolean' ||
|
||||
Object.prototype.hasOwnProperty.call(convo, 'expiredAt') ||
|
||||
Object.prototype.hasOwnProperty.call(unsetFields, 'isTemporary') ||
|
||||
Object.prototype.hasOwnProperty.call(unsetFields, 'expiredAt');
|
||||
/**
|
||||
* Saving a conversation that is itself archived or retention-hidden (e.g.
|
||||
* renaming or title generation on an archived project chat) must recompute
|
||||
* stats rather than take the incremental fast path, otherwise the project's
|
||||
* lastConversationAt/Id would point at a chat the project workspace hides.
|
||||
*/
|
||||
const isConversationHidden =
|
||||
conversation.isArchived === true ||
|
||||
conversation.isTemporary === true ||
|
||||
(conversation.expiredAt != null &&
|
||||
new Date(conversation.expiredAt).getTime() <= Date.now());
|
||||
/**
|
||||
* A move into this project (projectMembershipChanged) also needs a full
|
||||
* refresh: the incremental path only bumps the count for brand-new inserts,
|
||||
* so a pre-existing chat joining the project would otherwise be uncounted.
|
||||
*/
|
||||
const shouldRefreshProjectStats =
|
||||
projectMembershipChanged ||
|
||||
typeof update.isArchived === 'boolean' ||
|
||||
Object.prototype.hasOwnProperty.call(unsetFields, 'isArchived') ||
|
||||
isRetentionVisibilityUpdate ||
|
||||
isConversationHidden;
|
||||
|
||||
if (shouldRefreshProjectStats) {
|
||||
await refreshChatProjectStatsForUser(mongoose, userId, conversation.chatProjectId);
|
||||
} else {
|
||||
await updateChatProjectLastConversationForUser(
|
||||
mongoose,
|
||||
userId,
|
||||
conversation.chatProjectId,
|
||||
conversation,
|
||||
conversationResult.lastErrorObject?.updatedExisting === false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return conversation.toObject();
|
||||
} catch (error) {
|
||||
logger.error('[saveConvo] Error saving conversation', error);
|
||||
|
|
@ -299,19 +404,113 @@ export function createConversationMethods(
|
|||
async function bulkSaveConvos(conversations: Array<Record<string, unknown>>) {
|
||||
try {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const bulkOps = conversations.map((convo) => ({
|
||||
updateOne: {
|
||||
filter: {
|
||||
conversationId: convo.conversationId,
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
|
||||
/**
|
||||
* Validate project ownership before persisting (mirrors saveConvo). Bulk
|
||||
* paths like import/duplicate/fork can carry a chatProjectId that does not
|
||||
* belong to the user; persisting it would create an orphan assignment that
|
||||
* is hidden from both the project and the unassigned filter.
|
||||
*/
|
||||
const candidatePairs = new Map<string, { user: string; projectId: string }>();
|
||||
for (const convo of conversations) {
|
||||
if (
|
||||
typeof convo.user === 'string' &&
|
||||
typeof convo.chatProjectId === 'string' &&
|
||||
isValidObjectIdString(convo.chatProjectId)
|
||||
) {
|
||||
candidatePairs.set(`${convo.user}:${convo.chatProjectId}`, {
|
||||
user: convo.user,
|
||||
projectId: convo.chatProjectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ownedProjects = new Set<string>();
|
||||
if (candidatePairs.size > 0) {
|
||||
const owned = await ChatProject.find({
|
||||
$or: [...candidatePairs.values()].map(({ user, projectId }) => ({
|
||||
_id: new mongoose.Types.ObjectId(projectId),
|
||||
user,
|
||||
})),
|
||||
})
|
||||
.select('_id user')
|
||||
.lean<Array<{ _id: { toString: () => string }; user: string }>>();
|
||||
for (const project of owned) {
|
||||
ownedProjects.add(`${project.user}:${project._id.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture each conversation's existing project so a bulk move (import that
|
||||
* overwrites an existing (user, conversationId), duplicate/fork) also refreshes
|
||||
* the project it leaves, not just the one it joins. One batched read keeps this
|
||||
* O(1) in round-trips regardless of batch size.
|
||||
*/
|
||||
const previousProjectByConversation = new Map<string, string>();
|
||||
const conversationPairs = conversations
|
||||
.filter((c) => typeof c.user === 'string' && typeof c.conversationId === 'string')
|
||||
.map((c) => ({ user: c.user as string, conversationId: c.conversationId as string }));
|
||||
if (conversationPairs.length > 0) {
|
||||
const existing = await Conversation.find(
|
||||
{ $or: conversationPairs },
|
||||
'user conversationId chatProjectId',
|
||||
).lean<Array<{ user: string; conversationId: string; chatProjectId?: string | null }>>();
|
||||
for (const doc of existing) {
|
||||
if (doc.chatProjectId) {
|
||||
previousProjectByConversation.set(
|
||||
`${doc.user}:${doc.conversationId}`,
|
||||
doc.chatProjectId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const affectedProjectStats = new Map<string, { user: string; projectId: string }>();
|
||||
const bulkOps = conversations.map((convo) => {
|
||||
const sanitized = { ...convo };
|
||||
if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') {
|
||||
if (ownedProjects.has(`${sanitized.user}:${sanitized.chatProjectId}`)) {
|
||||
affectedProjectStats.set(`${sanitized.user}:${sanitized.chatProjectId}`, {
|
||||
user: sanitized.user,
|
||||
projectId: sanitized.chatProjectId,
|
||||
});
|
||||
} else {
|
||||
sanitized.chatProjectId = null;
|
||||
}
|
||||
}
|
||||
if (typeof sanitized.user === 'string' && typeof sanitized.conversationId === 'string') {
|
||||
const previousProjectId = previousProjectByConversation.get(
|
||||
`${sanitized.user}:${sanitized.conversationId}`,
|
||||
);
|
||||
const newProjectId =
|
||||
typeof sanitized.chatProjectId === 'string' ? sanitized.chatProjectId : null;
|
||||
if (previousProjectId && previousProjectId !== newProjectId) {
|
||||
affectedProjectStats.set(`${sanitized.user}:${previousProjectId}`, {
|
||||
user: sanitized.user,
|
||||
projectId: previousProjectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
updateOne: {
|
||||
filter: {
|
||||
conversationId: sanitized.conversationId,
|
||||
user: sanitized.user,
|
||||
},
|
||||
update: sanitized,
|
||||
upsert: true,
|
||||
timestamps: false,
|
||||
},
|
||||
update: convo,
|
||||
upsert: true,
|
||||
timestamps: false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const result = await tenantSafeBulkWrite(Conversation, bulkOps);
|
||||
await Promise.all(
|
||||
[...affectedProjectStats.values()].map(({ user, projectId }) =>
|
||||
refreshChatProjectStatsForUser(mongoose, user, projectId),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
|
|
@ -332,6 +531,7 @@ export function createConversationMethods(
|
|||
search,
|
||||
sortBy = 'updatedAt',
|
||||
sortDirection = 'desc',
|
||||
projectId,
|
||||
}: {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
|
|
@ -340,6 +540,7 @@ export function createConversationMethods(
|
|||
search?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: string;
|
||||
projectId?: string;
|
||||
} = {},
|
||||
) {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
|
|
@ -356,6 +557,14 @@ export function createConversationMethods(
|
|||
filters.push({ tags: { $in: tags } } as FilterQuery<IConversation>);
|
||||
}
|
||||
|
||||
if (projectId === 'unassigned') {
|
||||
filters.push({
|
||||
$or: [{ chatProjectId: null }, { chatProjectId: { $exists: false } }],
|
||||
} as FilterQuery<IConversation>);
|
||||
} else if (projectId) {
|
||||
filters.push({ chatProjectId: projectId } as FilterQuery<IConversation>);
|
||||
}
|
||||
|
||||
filters.push(getVisibleConversationRetentionFilter());
|
||||
|
||||
if (search) {
|
||||
|
|
@ -431,7 +640,7 @@ export function createConversationMethods(
|
|||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL chatProjectId',
|
||||
)
|
||||
.sort(sortObj)
|
||||
.limit(limit + 1)
|
||||
|
|
@ -538,8 +747,15 @@ export function createConversationMethods(
|
|||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const { deleteMessages } = getMessageMethods();
|
||||
const userFilter = { ...filter, user };
|
||||
const conversations = await Conversation.find(userFilter).select('conversationId');
|
||||
const conversations = await Conversation.find(userFilter).select(
|
||||
'conversationId chatProjectId',
|
||||
);
|
||||
const conversationIds = conversations.map((c) => c.conversationId);
|
||||
const projectIds = new Set(
|
||||
conversations
|
||||
.map((conversation) => conversation.chatProjectId)
|
||||
.filter((projectId): projectId is string => Boolean(projectId)),
|
||||
);
|
||||
|
||||
if (!conversationIds.length) {
|
||||
throw new Error('Conversation not found or already deleted.');
|
||||
|
|
@ -552,6 +768,12 @@ export function createConversationMethods(
|
|||
user,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
[...projectIds].map((projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, user, projectId),
|
||||
),
|
||||
);
|
||||
|
||||
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
||||
} catch (error) {
|
||||
logger.error('[deleteConvos] Error deleting conversations and messages', error);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ import { createPresetMethods, type PresetMethods } from './preset';
|
|||
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
|
||||
import { createMessageMethods, type MessageMethods } from './message';
|
||||
import { createConversationMethods, type ConversationMethods } from './conversation';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
export type {
|
||||
AssignConversationToProjectResult,
|
||||
ChatProjectSortBy,
|
||||
ChatProjectSortDirection,
|
||||
CreateChatProjectInput,
|
||||
DeleteChatProjectResult,
|
||||
ListChatProjectsOptions,
|
||||
ListChatProjectsResult,
|
||||
UpdateChatProjectInput,
|
||||
} from './chatProject';
|
||||
/* Tier 3 — Complex (heavier injection) */
|
||||
import {
|
||||
createTxMethods,
|
||||
|
|
@ -92,6 +103,7 @@ export type AllMethods = UserMethods &
|
|||
ConversationTagMethods &
|
||||
MessageMethods &
|
||||
ConversationMethods &
|
||||
ChatProjectMethods &
|
||||
TxMethods &
|
||||
TransactionMethods &
|
||||
SpendTokensMethods &
|
||||
|
|
@ -218,6 +230,7 @@ export function createMethods(
|
|||
...createConversationTagMethods(mongoose),
|
||||
...messageMethods,
|
||||
...conversationMethods,
|
||||
...createChatProjectMethods(mongoose),
|
||||
/* Tier 3 */
|
||||
...txMethods,
|
||||
...transactionMethods,
|
||||
|
|
@ -257,6 +270,7 @@ export type {
|
|||
ConversationTagMethods,
|
||||
MessageMethods,
|
||||
ConversationMethods,
|
||||
ChatProjectMethods,
|
||||
TxMethods,
|
||||
TransactionMethods,
|
||||
SpendTokensMethods,
|
||||
|
|
|
|||
11
packages/data-schemas/src/models/chatProject.ts
Normal file
11
packages/data-schemas/src/models/chatProject.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import chatProjectSchema from '~/schema/chatProject';
|
||||
import { applyTenantIsolation } from '~/models/plugins/tenantIsolation';
|
||||
import type { IChatProjectDocument } from '~/types';
|
||||
|
||||
export function createChatProjectModel(mongoose: typeof import('mongoose')) {
|
||||
applyTenantIsolation(chatProjectSchema);
|
||||
return (
|
||||
mongoose.models.ChatProject ||
|
||||
mongoose.model<IChatProjectDocument>('ChatProject', chatProjectSchema, 'chatprojects')
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { createTokenModel } from './token';
|
|||
import { createSessionModel } from './session';
|
||||
import { createBalanceModel } from './balance';
|
||||
import { createConversationModel } from './convo';
|
||||
import { createChatProjectModel } from './chatProject';
|
||||
import { createMessageModel } from './message';
|
||||
import { createAgentModel } from './agent';
|
||||
import { createAgentApiKeyModel } from './agentApiKey';
|
||||
|
|
@ -41,6 +42,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
|
|||
Session: createSessionModel(mongoose),
|
||||
Balance: createBalanceModel(mongoose),
|
||||
Conversation: createConversationModel(mongoose),
|
||||
ChatProject: createChatProjectModel(mongoose),
|
||||
Message: createMessageModel(mongoose),
|
||||
Agent: createAgentModel(mongoose),
|
||||
AgentApiKey: createAgentApiKeyModel(mongoose),
|
||||
|
|
|
|||
50
packages/data-schemas/src/schema/chatProject.ts
Normal file
50
packages/data-schemas/src/schema/chatProject.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Schema } from 'mongoose';
|
||||
import type { IChatProjectDocument } from '~/types';
|
||||
|
||||
const chatProjectSchema = new Schema<IChatProjectDocument>(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100,
|
||||
index: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true,
|
||||
maxlength: 1000,
|
||||
},
|
||||
user: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
lastConversationAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
index: true,
|
||||
},
|
||||
lastConversationId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
chatProjectSchema.index({ user: 1, name: 1, _id: 1 });
|
||||
chatProjectSchema.index({ user: 1, createdAt: -1, _id: -1 });
|
||||
chatProjectSchema.index({ user: 1, lastConversationAt: -1, _id: -1 });
|
||||
|
||||
export default chatProjectSchema;
|
||||
|
|
@ -34,6 +34,11 @@ const convoSchema: Schema<IConversation> = new Schema(
|
|||
default: [],
|
||||
meiliIndex: true,
|
||||
},
|
||||
chatProjectId: {
|
||||
type: String,
|
||||
default: null,
|
||||
index: true,
|
||||
},
|
||||
files: {
|
||||
type: [String],
|
||||
},
|
||||
|
|
@ -51,6 +56,8 @@ const convoSchema: Schema<IConversation> = new Schema(
|
|||
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
|
||||
convoSchema.index({ createdAt: 1, updatedAt: 1 });
|
||||
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
|
||||
convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 });
|
||||
convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 });
|
||||
|
||||
convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 });
|
||||
// index for MeiliSearch sync operations
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export { default as assistantSchema } from './assistant';
|
|||
export { default as balanceSchema } from './balance';
|
||||
export { default as bannerSchema } from './banner';
|
||||
export { default as categoriesSchema } from './categories';
|
||||
export { default as chatProjectSchema } from './chatProject';
|
||||
export { default as conversationTagSchema } from './conversationTag';
|
||||
export { default as convoSchema } from './convo';
|
||||
export { default as fileSchema } from './file';
|
||||
|
|
|
|||
16
packages/data-schemas/src/types/chatProject.ts
Normal file
16
packages/data-schemas/src/types/chatProject.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Document, Types } from 'mongoose';
|
||||
|
||||
export interface IChatProject {
|
||||
_id?: Types.ObjectId;
|
||||
name: string;
|
||||
description?: string;
|
||||
user: string;
|
||||
conversationCount: number;
|
||||
lastConversationAt?: Date | null;
|
||||
lastConversationId?: string | null;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface IChatProjectDocument extends Omit<IChatProject, '_id'>, Document {}
|
||||
|
|
@ -42,6 +42,7 @@ export interface IConversation extends Document {
|
|||
greeting?: string;
|
||||
spec?: string;
|
||||
tags?: string[];
|
||||
chatProjectId?: string | null;
|
||||
tools?: string[];
|
||||
maxContextTokens?: number;
|
||||
max_tokens?: number;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export * from './app';
|
|||
export * from './user';
|
||||
export * from './token';
|
||||
export * from './convo';
|
||||
export * from './chatProject';
|
||||
export * from './session';
|
||||
export * from './balance';
|
||||
export * from './banner';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue