From 29ef91b4dd53d500c7d5536e37ad6072514099fc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Jun 2025 18:52:22 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20feat:=20User=20Memories=20for=20?= =?UTF-8?q?Conversational=20Context=20(#7760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧠 feat: User Memories for Conversational Context chore: mcp typing, use `t` WIP: first pass, Memories UI - Added MemoryViewer component for displaying, editing, and deleting user memories. - Integrated data provider hooks for fetching, updating, and deleting memories. - Implemented pagination and loading states for better user experience. - Created unit tests for MemoryViewer to ensure functionality and interaction with data provider. - Updated translation files to include new UI strings related to memories. chore: move mcp-related files to own directory chore: rename librechat-mcp to librechat-api WIP: first pass, memory processing and data schemas chore: linting in fileSearch.js query description chore: rename librechat-api to @librechat/api across the project WIP: first pass, functional memory agent feat: add MemoryEditDialog and MemoryViewer components for managing user memories - Introduced MemoryEditDialog for editing memory entries with validation and toast notifications. - Updated MemoryViewer to support editing and deleting memories, including pagination and loading states. - Enhanced data provider to handle memory updates with optional original key for better management. - Added new localization strings for memory-related UI elements. feat: add memory permissions management - Implemented memory permissions in the backend, allowing roles to have specific permissions for using, creating, updating, and reading memories. - Added new API endpoints for updating memory permissions associated with roles. - Created a new AdminSettings component for managing memory permissions in the frontend. - Integrated memory permissions into the existing roles and permissions schemas. - Updated the interface to include memory settings and permissions. - Enhanced the MemoryViewer component to conditionally render admin settings based on user roles. - Added localization support for memory permissions in the translation files. feat: move AdminSettings component to a new position in MemoryViewer for better visibility refactor: clean up commented code in MemoryViewer component feat: enhance MemoryViewer with search functionality and improve MemoryEditDialog integration - Added a search input to filter memories in the MemoryViewer component. - Refactored MemoryEditDialog to accept children for better customization. - Updated MemoryViewer to utilize the new EditMemoryButton and DeleteMemoryButton components for editing and deleting memories. - Improved localization support by adding new strings for memory filtering and deletion confirmation. refactor: optimize memory filtering in MemoryViewer using match-sorter - Replaced manual filtering logic with match-sorter for improved search functionality. - Enhanced performance and readability of the filteredMemories computation. feat: enhance MemoryEditDialog with triggerRef and improve updateMemory mutation handling feat: implement access control for MemoryEditDialog and MemoryViewer components refactor: remove commented out code and create runMemory method refactor: rename role based files feat: implement access control for memory usage in AgentClient refactor: simplify checkVisionRequest method in AgentClient by removing commented-out code refactor: make `agents` dir in api package refactor: migrate Azure utilities to TypeScript and consolidate imports refactor: move sanitizeFilename function to a new file and update imports, add related tests refactor: update LLM configuration types and consolidate Azure options in the API package chore: linting chore: import order refactor: replace getLLMConfig with getOpenAIConfig and remove unused LLM configuration file chore: update winston-daily-rotate-file to version 5.0.0 and add object-hash dependency in package-lock.json refactor: move primeResources and optionalChainWithEmptyCheck functions to resources.ts and update imports refactor: move createRun function to a new run.ts file and update related imports fix: ensure safeAttachments is correctly typed as an array of TFile chore: add node-fetch dependency and refactor fetch-related functions into packages/api/utils, removing the old generators file refactor: enhance TEndpointOption type by using Pick to streamline endpoint fields and add new properties for model parameters and client options feat: implement initializeOpenAIOptions function and update OpenAI types for enhanced configuration handling fix: update types due to new TEndpointOption typing fix: ensure safe access to group parameters in initializeOpenAIOptions function fix: remove redundant API key validation comment in initializeOpenAIOptions function refactor: rename initializeOpenAIOptions to initializeOpenAI for consistency and update related documentation refactor: decouple req.body fields and tool loading from initializeAgentOptions chore: linting refactor: adjust column widths in MemoryViewer for improved layout refactor: simplify agent initialization by creating loadAgent function and removing unused code feat: add memory configuration loading and validation functions WIP: first pass, memory processing with config feat: implement memory callback and artifact handling feat: implement memory artifacts display and processing updates feat: add memory configuration options and schema validation for validKeys fix: update MemoryEditDialog and MemoryViewer to handle memory state and display improvements refactor: remove padding from BookmarkTable and MemoryViewer headers for consistent styling WIP: initial tokenLimit config and move Tokenizer to @librechat/api refactor: update mongoMeili plugin methods to use callback for better error handling feat: enhance memory management with token tracking and usage metrics - Added token counting for memory entries to enforce limits and provide usage statistics. - Updated memory retrieval and update routes to include total token usage and limit. - Enhanced MemoryEditDialog and MemoryViewer components to display memory usage and token information. - Refactored memory processing functions to handle token limits and provide feedback on memory capacity. feat: implement memory artifact handling in attachment handler - Enhanced useAttachmentHandler to process memory artifacts when receiving updates. - Introduced handleMemoryArtifact utility to manage memory updates and deletions. - Updated query client to reflect changes in memory state based on incoming data. refactor: restructure web search key extraction logic - Moved the logic for extracting API keys from the webSearchAuth configuration into a dedicated function, getWebSearchKeys. - Updated webSearchKeys to utilize the new function for improved clarity and maintainability. - Prevents build time errors feat: add personalization settings and memory preferences management - Introduced a new Personalization tab in settings to manage user memory preferences. - Implemented API endpoints and client-side logic for updating memory preferences. - Enhanced user interface components to reflect personalization options and memory usage. - Updated permissions to allow users to opt out of memory features. - Added localization support for new settings and messages related to personalization. style: personalization switch class feat: add PersonalizationIcon and align Side Panel UI feat: implement memory creation functionality - Added a new API endpoint for creating memory entries, including validation for key and value. - Introduced MemoryCreateDialog component for user interface to facilitate memory creation. - Integrated token limit checks to prevent exceeding user memory capacity. - Updated MemoryViewer to include a button for opening the memory creation dialog. - Enhanced localization support for new messages related to memory creation. feat: enhance message processing with configurable window size - Updated AgentClient to use a configurable message window size for processing messages. - Introduced messageWindowSize option in memory configuration schema with a default value of 5. - Improved logic for selecting messages to process based on the configured window size. chore: update librechat-data-provider version to 0.7.87 in package.json and package-lock.json chore: remove OpenAPIPlugin and its associated tests chore: remove MIGRATION_README.md as migration tasks are completed ci: fix backend tests chore: remove unused translation keys from localization file chore: remove problematic test file and unused var in AgentClient chore: remove unused import and import directly for JSDoc * feat: add api package build stage in Dockerfile for improved modularity * docs: reorder build steps in contributing guide for clarity --- .github/CONTRIBUTING.md | 4 +- .github/workflows/backend-review.yml | 11 +- Dockerfile.multi | 23 +- api/app/clients/AnthropicClient.js | 3 +- api/app/clients/ChatGPTClient.js | 3 +- api/app/clients/GoogleClient.js | 2 +- api/app/clients/OpenAIClient.js | 20 +- api/app/clients/generators.js | 71 -- api/app/clients/llm/createLLM.js | 3 +- api/app/clients/specs/BaseClient.test.js | 2 + .../clients/tools/dynamic/OpenAPIPlugin.js | 184 ----- .../tools/dynamic/OpenAPIPlugin.spec.js | 72 -- api/app/clients/tools/structured/DALLE3.js | 4 +- .../tools/structured/specs/DALLE3.spec.js | 28 +- api/app/clients/tools/util/fileSearch.js | 2 +- api/config/index.js | 4 +- api/package.json | 5 +- api/server/cleanup.js | 3 + api/server/controllers/agents/callbacks.js | 5 +- api/server/controllers/agents/client.js | 357 ++++----- api/server/controllers/agents/run.js | 94 --- api/server/index.js | 2 +- .../{generateCheckAccess.js => access.js} | 0 .../roles/{checkAdmin.js => admin.js} | 0 api/server/middleware/roles/index.js | 4 +- api/server/routes/files/multer.js | 2 +- api/server/routes/index.js | 2 + api/server/routes/memories.js | 231 ++++++ api/server/routes/roles.js | 40 + api/server/services/ActionService.js | 4 +- api/server/services/AppService.js | 3 + api/server/services/Endpoints/agents/agent.js | 196 +++++ .../services/Endpoints/agents/initialize.js | 327 ++------ .../Endpoints/azureAssistants/initialize.js | 3 +- .../services/Endpoints/bedrock/options.js | 2 +- .../services/Endpoints/custom/initialize.js | 5 +- .../Endpoints/gptPlugins/initialize.js | 5 +- .../Endpoints/gptPlugins/initialize.spec.js | 10 +- .../services/Endpoints/openAI/initialize.js | 13 +- api/server/services/Endpoints/openAI/llm.js | 170 ----- api/server/services/Files/Audio/STTService.js | 2 +- api/server/services/Files/Audio/TTSService.js | 2 +- api/server/services/MCP.js | 2 +- api/server/services/start/interface.js | 18 + api/server/services/start/interface.spec.js | 20 + api/server/utils/handleText.js | 35 - api/server/utils/handleText.spec.js | 103 --- api/typedefs.js | 14 +- api/utils/index.js | 2 - .../Chat/Messages/Content/ContentParts.tsx | 3 + .../Chat/Messages/Content/MemoryArtifacts.tsx | 143 ++++ .../Chat/Messages/Content/MemoryInfo.tsx | 61 ++ client/src/components/Nav/Settings.tsx | 49 +- .../Nav/SettingsTabs/Personalization.tsx | 87 +++ .../src/components/Nav/SettingsTabs/index.ts | 1 + .../Prompts/Groups/FilterPrompts.tsx | 10 +- .../Prompts/Groups/GroupSidePanel.tsx | 20 +- .../Prompts/Groups/PanelNavigation.tsx | 14 +- .../components/Prompts/PromptsAccordion.tsx | 8 +- .../SidePanel/Bookmarks/BookmarkTable.tsx | 6 +- .../SidePanel/Memories/AdminSettings.tsx | 212 ++++++ .../SidePanel/Memories/MemoryCreateDialog.tsx | 147 ++++ .../SidePanel/Memories/MemoryEditDialog.tsx | 179 +++++ .../SidePanel/Memories/MemoryViewer.tsx | 428 +++++++++++ .../components/SidePanel/Memories/index.ts | 2 + .../components/svg/PersonalizationIcon.tsx | 19 + client/src/components/svg/index.ts | 1 + client/src/components/ui/OriginalDialog.tsx | 2 +- client/src/components/ui/Table.tsx | 8 +- client/src/data-provider/Memories/index.ts | 2 + client/src/data-provider/Memories/queries.ts | 116 +++ .../data-provider/__tests__/memories.test.ts | 19 + client/src/data-provider/index.ts | 2 + client/src/data-provider/roles.ts | 45 +- client/src/hooks/Nav/useSideNavLinks.ts | 23 +- client/src/hooks/SSE/useAttachmentHandler.ts | 17 +- client/src/hooks/usePersonalizationAccess.ts | 16 + client/src/locales/en/translation.json | 39 +- client/src/utils/memory.ts | 90 +++ config/packages.js | 4 +- config/update.js | 8 +- eslint.config.mjs | 12 +- librechat.example.yaml | 22 + package-lock.json | 381 +++++----- package.json | 6 +- packages/{mcp => api}/.gitignore | 0 packages/{mcp => api}/babel.config.cjs | 0 packages/{mcp => api}/jest.config.mjs | 0 packages/{mcp => api}/package.json | 17 +- packages/{mcp => api}/rollup.config.js | 0 packages/api/src/agents/index.ts | 3 + packages/api/src/agents/memory.ts | 468 ++++++++++++ packages/api/src/agents/resources.test.ts | 543 ++++++++++++++ packages/api/src/agents/resources.ts | 114 +++ packages/api/src/agents/run.ts | 90 +++ packages/api/src/endpoints/index.ts | 1 + packages/api/src/endpoints/openai/index.ts | 2 + .../api/src/endpoints/openai/initialize.ts | 176 +++++ packages/api/src/endpoints/openai/llm.ts | 156 ++++ .../{mcp => api}/src/flow/manager.spec.ts | 0 packages/{mcp => api}/src/flow/manager.ts | 0 packages/{mcp => api}/src/flow/types.ts | 0 packages/api/src/index.ts | 14 + .../{mcp/src => api/src/mcp}/connection.ts | 4 +- packages/{mcp/src => api/src/mcp}/enum.ts | 0 packages/{mcp/src => api/src/mcp}/manager.ts | 2 +- packages/{mcp/src => api/src/mcp}/parsers.ts | 2 +- .../mcp.ts => api/src/mcp/types/index.ts} | 5 +- .../{mcp/src => api/src/mcp}/utils.test.ts | 0 packages/{mcp/src => api/src/mcp}/utils.ts | 0 packages/api/src/types/azure.ts | 19 + packages/api/src/types/events.ts | 4 + packages/api/src/types/index.ts | 4 + packages/api/src/types/openai.ts | 97 +++ packages/api/src/types/run.ts | 10 + .../api/src/utils/azure.spec.ts | 15 +- .../api/src/utils/azure.ts | 99 +-- packages/api/src/utils/common.spec.ts | 55 ++ packages/api/src/utils/common.ts | 48 ++ packages/api/src/utils/events.ts | 16 + packages/api/src/utils/files.spec.ts | 115 +++ packages/api/src/utils/files.ts | 33 + packages/api/src/utils/generators.ts | 75 ++ packages/api/src/utils/index.ts | 5 + .../api/src/utils/tokenizer.spec.ts | 21 +- .../api/src/utils/tokenizer.ts | 34 +- .../{mcp => api}/tsconfig-paths-bootstrap.mjs | 0 packages/{mcp => api}/tsconfig.json | 5 +- packages/{mcp => api}/tsconfig.spec.json | 0 packages/data-provider/package.json | 2 +- packages/data-provider/src/api-endpoints.ts | 6 + packages/data-provider/src/config.ts | 30 + packages/data-provider/src/createPayload.ts | 5 +- packages/data-provider/src/data-service.ts | 36 + packages/data-provider/src/index.ts | 2 + packages/data-provider/src/keys.ts | 3 + packages/data-provider/src/memory.ts | 62 ++ packages/data-provider/src/parsers.ts | 4 +- packages/data-provider/src/permissions.ts | 16 + packages/data-provider/src/roles.ts | 16 + packages/data-provider/src/schemas.ts | 8 + packages/data-provider/src/types.ts | 98 ++- .../data-provider/src/types/assistants.ts | 1 + packages/data-provider/src/types/mutations.ts | 9 +- packages/data-provider/src/types/queries.ts | 15 + packages/data-provider/src/web.ts | 28 +- packages/data-schemas/README.md | 114 --- packages/data-schemas/src/index.ts | 1 + packages/data-schemas/src/methods/index.ts | 6 +- packages/data-schemas/src/methods/memory.ts | 168 +++++ packages/data-schemas/src/methods/user.ts | 30 + packages/data-schemas/src/models/index.ts | 2 + packages/data-schemas/src/models/memory.ts | 6 + .../src/models/plugins/mongoMeili.ts | 125 +++- packages/data-schemas/src/schema/index.ts | 1 + packages/data-schemas/src/schema/memory.ts | 33 + packages/data-schemas/src/schema/role.ts | 13 + packages/data-schemas/src/schema/user.ts | 9 + packages/data-schemas/src/types/index.ts | 5 + packages/data-schemas/src/types/memory.ts | 48 ++ packages/data-schemas/src/types/role.ts | 6 + packages/data-schemas/src/types/user.ts | 3 + packages/mcp/src/demo/everything.ts | 231 ------ packages/mcp/src/demo/filesystem.ts | 211 ------ packages/mcp/src/demo/servers.ts | 226 ------ .../mcp/src/examples/everything/everything.ts | 426 ----------- packages/mcp/src/examples/everything/index.ts | 23 - packages/mcp/src/examples/everything/sse.ts | 24 - packages/mcp/src/examples/filesystem.ts | 700 ------------------ packages/mcp/src/index.ts | 9 - 170 files changed, 5700 insertions(+), 3632 deletions(-) delete mode 100644 api/app/clients/generators.js delete mode 100644 api/app/clients/tools/dynamic/OpenAPIPlugin.js delete mode 100644 api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js delete mode 100644 api/server/controllers/agents/run.js rename api/server/middleware/roles/{generateCheckAccess.js => access.js} (100%) rename api/server/middleware/roles/{checkAdmin.js => admin.js} (100%) create mode 100644 api/server/routes/memories.js create mode 100644 api/server/services/Endpoints/agents/agent.js delete mode 100644 api/server/services/Endpoints/openAI/llm.js delete mode 100644 api/server/utils/handleText.spec.js create mode 100644 client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx create mode 100644 client/src/components/Chat/Messages/Content/MemoryInfo.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Personalization.tsx create mode 100644 client/src/components/SidePanel/Memories/AdminSettings.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryEditDialog.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryViewer.tsx create mode 100644 client/src/components/SidePanel/Memories/index.ts create mode 100644 client/src/components/svg/PersonalizationIcon.tsx create mode 100644 client/src/data-provider/Memories/index.ts create mode 100644 client/src/data-provider/Memories/queries.ts create mode 100644 client/src/data-provider/__tests__/memories.test.ts create mode 100644 client/src/hooks/usePersonalizationAccess.ts create mode 100644 client/src/utils/memory.ts rename packages/{mcp => api}/.gitignore (100%) rename packages/{mcp => api}/babel.config.cjs (100%) rename packages/{mcp => api}/jest.config.mjs (100%) rename packages/{mcp => api}/package.json (90%) rename packages/{mcp => api}/rollup.config.js (100%) create mode 100644 packages/api/src/agents/index.ts create mode 100644 packages/api/src/agents/memory.ts create mode 100644 packages/api/src/agents/resources.test.ts create mode 100644 packages/api/src/agents/resources.ts create mode 100644 packages/api/src/agents/run.ts create mode 100644 packages/api/src/endpoints/index.ts create mode 100644 packages/api/src/endpoints/openai/index.ts create mode 100644 packages/api/src/endpoints/openai/initialize.ts create mode 100644 packages/api/src/endpoints/openai/llm.ts rename packages/{mcp => api}/src/flow/manager.spec.ts (100%) rename packages/{mcp => api}/src/flow/manager.ts (100%) rename packages/{mcp => api}/src/flow/types.ts (100%) create mode 100644 packages/api/src/index.ts rename packages/{mcp/src => api/src/mcp}/connection.ts (99%) rename packages/{mcp/src => api/src/mcp}/enum.ts (100%) rename packages/{mcp/src => api/src/mcp}/manager.ts (99%) rename packages/{mcp/src => api/src/mcp}/parsers.ts (99%) rename packages/{mcp/src/types/mcp.ts => api/src/mcp/types/index.ts} (92%) rename packages/{mcp/src => api/src/mcp}/utils.test.ts (100%) rename packages/{mcp/src => api/src/mcp}/utils.ts (100%) create mode 100644 packages/api/src/types/azure.ts create mode 100644 packages/api/src/types/events.ts create mode 100644 packages/api/src/types/index.ts create mode 100644 packages/api/src/types/openai.ts create mode 100644 packages/api/src/types/run.ts rename api/utils/azureUtils.spec.js => packages/api/src/utils/azure.spec.ts (97%) rename api/utils/azureUtils.js => packages/api/src/utils/azure.ts (50%) create mode 100644 packages/api/src/utils/common.spec.ts create mode 100644 packages/api/src/utils/common.ts create mode 100644 packages/api/src/utils/events.ts create mode 100644 packages/api/src/utils/files.spec.ts create mode 100644 packages/api/src/utils/files.ts create mode 100644 packages/api/src/utils/generators.ts create mode 100644 packages/api/src/utils/index.ts rename api/server/services/Tokenizer.spec.js => packages/api/src/utils/tokenizer.spec.ts (90%) rename api/server/services/Tokenizer.js => packages/api/src/utils/tokenizer.ts (56%) rename packages/{mcp => api}/tsconfig-paths-bootstrap.mjs (100%) rename packages/{mcp => api}/tsconfig.json (92%) rename packages/{mcp => api}/tsconfig.spec.json (100%) create mode 100644 packages/data-provider/src/memory.ts delete mode 100644 packages/data-schemas/README.md create mode 100644 packages/data-schemas/src/methods/memory.ts create mode 100644 packages/data-schemas/src/models/memory.ts create mode 100644 packages/data-schemas/src/schema/memory.ts create mode 100644 packages/data-schemas/src/types/memory.ts delete mode 100644 packages/mcp/src/demo/everything.ts delete mode 100644 packages/mcp/src/demo/filesystem.ts delete mode 100644 packages/mcp/src/demo/servers.ts delete mode 100644 packages/mcp/src/examples/everything/everything.ts delete mode 100644 packages/mcp/src/examples/everything/index.ts delete mode 100644 packages/mcp/src/examples/everything/sse.ts delete mode 100644 packages/mcp/src/examples/filesystem.ts delete mode 100644 packages/mcp/src/index.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 09444a1b4..207aa17e6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -30,8 +30,8 @@ Project maintainers have the right and responsibility to remove, edit, or reject 2. Install typescript globally: `npm i -g typescript`. 3. Run `npm ci` to install dependencies. 4. Build the data provider: `npm run build:data-provider`. -5. Build MCP: `npm run build:mcp`. -6. Build data schemas: `npm run build:data-schemas`. +5. Build data schemas: `npm run build:data-schemas`. +6. Build API methods: `npm run build:api`. 7. Setup and run unit tests: - Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`. - Run backend unit tests: `npm run test:api`. diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index b7bccecae..0c593eac1 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -7,6 +7,7 @@ on: - release/* paths: - 'api/**' + - 'packages/api/**' jobs: tests_Backend: name: Run Backend unit tests @@ -36,12 +37,12 @@ jobs: - name: Install Data Provider Package run: npm run build:data-provider - - name: Install MCP Package - run: npm run build:mcp - - name: Install Data Schemas Package run: npm run build:data-schemas + - name: Install API Package + run: npm run build:api + - name: Create empty auth.json file run: | mkdir -p api/data @@ -66,5 +67,5 @@ jobs: - name: Run librechat-data-provider unit tests run: cd packages/data-provider && npm run test:ci - - name: Run librechat-mcp unit tests - run: cd packages/mcp && npm run test:ci \ No newline at end of file + - name: Run librechat-api unit tests + run: cd packages/api && npm run test:ci \ No newline at end of file diff --git a/Dockerfile.multi b/Dockerfile.multi index 991f805be..26c570537 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -14,7 +14,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \ npm config set fetch-retry-mintimeout 15000 COPY package*.json ./ COPY packages/data-provider/package*.json ./packages/data-provider/ -COPY packages/mcp/package*.json ./packages/mcp/ +COPY packages/api/package*.json ./packages/api/ COPY packages/data-schemas/package*.json ./packages/data-schemas/ COPY client/package*.json ./client/ COPY api/package*.json ./api/ @@ -24,26 +24,27 @@ FROM base-min AS base WORKDIR /app RUN npm ci -# Build data-provider +# Build `data-provider` package FROM base AS data-provider-build WORKDIR /app/packages/data-provider COPY packages/data-provider ./ RUN npm run build -# Build mcp package -FROM base AS mcp-build -WORKDIR /app/packages/mcp -COPY packages/mcp ./ -COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist -RUN npm run build - -# Build data-schemas +# Build `data-schemas` package FROM base AS data-schemas-build WORKDIR /app/packages/data-schemas COPY packages/data-schemas ./ COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist RUN npm run build +# Build `api` package +FROM base AS api-build +WORKDIR /app/packages/api +COPY packages/api ./ +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist +RUN npm run build + # Client build FROM base AS client-build WORKDIR /app/client @@ -63,8 +64,8 @@ RUN npm ci --omit=dev COPY api ./api COPY config ./config COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist -COPY --from=mcp-build /app/packages/mcp/dist ./packages/mcp/dist COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist +COPY --from=api-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist WORKDIR /app/api EXPOSE 3080 diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 0da331ced..037f1e7c4 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -10,6 +10,7 @@ const { validateVisionModel, } = require('librechat-data-provider'); const { SplitStreamHandler: _Handler } = require('@librechat/agents'); +const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api'); const { truncateText, formatMessage, @@ -26,8 +27,6 @@ const { const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { createFetch, createStreamEventHandlers } = require('./generators'); -const Tokenizer = require('~/server/services/Tokenizer'); const { sleep } = require('~/server/utils'); const BaseClient = require('./BaseClient'); const { logger } = require('~/config'); diff --git a/api/app/clients/ChatGPTClient.js b/api/app/clients/ChatGPTClient.js index 36a3f4936..555028dc3 100644 --- a/api/app/clients/ChatGPTClient.js +++ b/api/app/clients/ChatGPTClient.js @@ -2,6 +2,7 @@ const { Keyv } = require('keyv'); const crypto = require('crypto'); const { CohereClient } = require('cohere-ai'); const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source'); +const { constructAzureURL, genAzureChatCompletion } = require('@librechat/api'); const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); const { ImageDetail, @@ -10,9 +11,9 @@ const { CohereConstants, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { extractBaseURL, constructAzureURL, genAzureChatCompletion } = require('~/utils'); const { createContextHandlers } = require('./prompts'); const { createCoherePayload } = require('./llm'); +const { extractBaseURL } = require('~/utils'); const BaseClient = require('./BaseClient'); const { logger } = require('~/config'); diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 4151e6663..2ded0657c 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -1,4 +1,5 @@ const { google } = require('googleapis'); +const { Tokenizer } = require('@librechat/api'); const { concat } = require('@langchain/core/utils/stream'); const { ChatVertexAI } = require('@langchain/google-vertexai'); const { ChatGoogleGenerativeAI } = require('@langchain/google-genai'); @@ -19,7 +20,6 @@ const { } = require('librechat-data-provider'); const { getSafetySettings } = require('~/server/services/Endpoints/google/llm'); const { encodeAndFormat } = require('~/server/services/Files/images'); -const Tokenizer = require('~/server/services/Tokenizer'); const { spendTokens } = require('~/models/spendTokens'); const { getModelMaxTokens } = require('~/utils'); const { sleep } = require('~/server/utils'); diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 280db8928..f3a7e67c1 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,14 @@ const { OllamaClient } = require('./OllamaClient'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); +const { + isEnabled, + Tokenizer, + createFetch, + constructAzureURL, + genAzureChatCompletion, + createStreamEventHandlers, +} = require('@librechat/api'); const { Constants, ImageDetail, @@ -16,13 +24,6 @@ const { validateVisionModel, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { - extractBaseURL, - constructAzureURL, - getModelMaxTokens, - genAzureChatCompletion, - getModelMaxOutputTokens, -} = require('~/utils'); const { truncateText, formatMessage, @@ -30,10 +31,9 @@ const { titleInstruction, createContextHandlers, } = require('./prompts'); +const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { createFetch, createStreamEventHandlers } = require('./generators'); -const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils'); -const Tokenizer = require('~/server/services/Tokenizer'); +const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { spendTokens } = require('~/models/spendTokens'); const { handleOpenAIErrors } = require('./tools/util'); const { createLLM, RunManager } = require('./llm'); diff --git a/api/app/clients/generators.js b/api/app/clients/generators.js deleted file mode 100644 index 9814cac7a..000000000 --- a/api/app/clients/generators.js +++ /dev/null @@ -1,71 +0,0 @@ -const fetch = require('node-fetch'); -const { GraphEvents } = require('@librechat/agents'); -const { logger, sendEvent } = require('~/config'); -const { sleep } = require('~/server/utils'); - -/** - * Makes a function to make HTTP request and logs the process. - * @param {Object} params - * @param {boolean} [params.directEndpoint] - Whether to use a direct endpoint. - * @param {string} [params.reverseProxyUrl] - The reverse proxy URL to use for the request. - * @returns {Promise} - A promise that resolves to the response of the fetch request. - */ -function createFetch({ directEndpoint = false, reverseProxyUrl = '' }) { - /** - * Makes an HTTP request and logs the process. - * @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object. - * @param {RequestInit} [init] - Optional init options for the request. - * @returns {Promise} - A promise that resolves to the response of the fetch request. - */ - return async (_url, init) => { - let url = _url; - if (directEndpoint) { - url = reverseProxyUrl; - } - logger.debug(`Making request to ${url}`); - if (typeof Bun !== 'undefined') { - return await fetch(url, init); - } - return await fetch(url, init); - }; -} - -// Add this at the module level outside the class -/** - * Creates event handlers for stream events that don't capture client references - * @param {Object} res - The response object to send events to - * @returns {Object} Object containing handler functions - */ -function createStreamEventHandlers(res) { - return { - [GraphEvents.ON_RUN_STEP]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - [GraphEvents.ON_MESSAGE_DELTA]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - [GraphEvents.ON_REASONING_DELTA]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - }; -} - -function createHandleLLMNewToken(streamRate) { - return async () => { - if (streamRate) { - await sleep(streamRate); - } - }; -} - -module.exports = { - createFetch, - createHandleLLMNewToken, - createStreamEventHandlers, -}; diff --git a/api/app/clients/llm/createLLM.js b/api/app/clients/llm/createLLM.js index c8d6666bc..846c4d8e9 100644 --- a/api/app/clients/llm/createLLM.js +++ b/api/app/clients/llm/createLLM.js @@ -1,6 +1,5 @@ const { ChatOpenAI } = require('@langchain/openai'); -const { sanitizeModelName, constructAzureURL } = require('~/utils'); -const { isEnabled } = require('~/server/utils'); +const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api'); /** * Creates a new instance of a language model (LLM) for chat interactions. diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 0ba77db6f..6d4491580 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -33,7 +33,9 @@ jest.mock('~/models', () => ({ const { getConvo, saveConvo } = require('~/models'); jest.mock('@librechat/agents', () => { + const { Providers } = jest.requireActual('@librechat/agents'); return { + Providers, ChatOpenAI: jest.fn().mockImplementation(() => { return {}; }), diff --git a/api/app/clients/tools/dynamic/OpenAPIPlugin.js b/api/app/clients/tools/dynamic/OpenAPIPlugin.js deleted file mode 100644 index acc3a64d3..000000000 --- a/api/app/clients/tools/dynamic/OpenAPIPlugin.js +++ /dev/null @@ -1,184 +0,0 @@ -require('dotenv').config(); -const fs = require('fs'); -const { z } = require('zod'); -const path = require('path'); -const yaml = require('js-yaml'); -const { createOpenAPIChain } = require('langchain/chains'); -const { DynamicStructuredTool } = require('@langchain/core/tools'); -const { ChatPromptTemplate, HumanMessagePromptTemplate } = require('@langchain/core/prompts'); -const { logger } = require('~/config'); - -function addLinePrefix(text, prefix = '// ') { - return text - .split('\n') - .map((line) => prefix + line) - .join('\n'); -} - -function createPrompt(name, functions) { - const prefix = `// The ${name} tool has the following functions. Determine the desired or most optimal function for the user's query:`; - const functionDescriptions = functions - .map((func) => `// - ${func.name}: ${func.description}`) - .join('\n'); - return `${prefix}\n${functionDescriptions} -// You are an expert manager and scrum master. You must provide a detailed intent to better execute the function. -// Always format as such: {{"func": "function_name", "intent": "intent and expected result"}}`; -} - -const AuthBearer = z - .object({ - type: z.string().includes('service_http'), - authorization_type: z.string().includes('bearer'), - verification_tokens: z.object({ - openai: z.string(), - }), - }) - .catch(() => false); - -const AuthDefinition = z - .object({ - type: z.string(), - authorization_type: z.string(), - verification_tokens: z.object({ - openai: z.string(), - }), - }) - .catch(() => false); - -async function readSpecFile(filePath) { - try { - const fileContents = await fs.promises.readFile(filePath, 'utf8'); - if (path.extname(filePath) === '.json') { - return JSON.parse(fileContents); - } - return yaml.load(fileContents); - } catch (e) { - logger.error('[readSpecFile] error', e); - return false; - } -} - -async function getSpec(url) { - const RegularUrl = z - .string() - .url() - .catch(() => false); - - if (RegularUrl.parse(url) && path.extname(url) === '.json') { - const response = await fetch(url); - return await response.json(); - } - - const ValidSpecPath = z - .string() - .url() - .catch(async () => { - const spec = path.join(__dirname, '..', '.well-known', 'openapi', url); - if (!fs.existsSync(spec)) { - return false; - } - - return await readSpecFile(spec); - }); - - return ValidSpecPath.parse(url); -} - -async function createOpenAPIPlugin({ data, llm, user, message, memory, signal }) { - let spec; - try { - spec = await getSpec(data.api.url); - } catch (error) { - logger.error('[createOpenAPIPlugin] getSpec error', error); - return null; - } - - if (!spec) { - logger.warn('[createOpenAPIPlugin] No spec found'); - return null; - } - - const headers = {}; - const { auth, name_for_model, description_for_model, description_for_human } = data; - if (auth && AuthDefinition.parse(auth)) { - logger.debug('[createOpenAPIPlugin] auth detected', auth); - const { openai } = auth.verification_tokens; - if (AuthBearer.parse(auth)) { - headers.authorization = `Bearer ${openai}`; - logger.debug('[createOpenAPIPlugin] added auth bearer', headers); - } - } - - const chainOptions = { llm }; - - if (data.headers && data.headers['librechat_user_id']) { - logger.debug('[createOpenAPIPlugin] id detected', headers); - headers[data.headers['librechat_user_id']] = user; - } - - if (Object.keys(headers).length > 0) { - logger.debug('[createOpenAPIPlugin] headers detected', headers); - chainOptions.headers = headers; - } - - if (data.params) { - logger.debug('[createOpenAPIPlugin] params detected', data.params); - chainOptions.params = data.params; - } - - let history = ''; - if (memory) { - logger.debug('[createOpenAPIPlugin] openAPI chain: memory detected', memory); - const { history: chat_history } = await memory.loadMemoryVariables({}); - history = chat_history?.length > 0 ? `\n\n## Chat History:\n${chat_history}\n` : ''; - } - - chainOptions.prompt = ChatPromptTemplate.fromMessages([ - HumanMessagePromptTemplate.fromTemplate( - `# Use the provided API's to respond to this query:\n\n{query}\n\n## Instructions:\n${addLinePrefix( - description_for_model, - )}${history}`, - ), - ]); - - const chain = await createOpenAPIChain(spec, chainOptions); - - const { functions } = chain.chains[0].lc_kwargs.llmKwargs; - - return new DynamicStructuredTool({ - name: name_for_model, - description_for_model: `${addLinePrefix(description_for_human)}${createPrompt( - name_for_model, - functions, - )}`, - description: `${description_for_human}`, - schema: z.object({ - func: z - .string() - .describe( - `The function to invoke. The functions available are: ${functions - .map((func) => func.name) - .join(', ')}`, - ), - intent: z - .string() - .describe('Describe your intent with the function and your expected result'), - }), - func: async ({ func = '', intent = '' }) => { - const filteredFunctions = functions.filter((f) => f.name === func); - chain.chains[0].lc_kwargs.llmKwargs.functions = filteredFunctions; - const query = `${message}${func?.length > 0 ? `\n// Intent: ${intent}` : ''}`; - const result = await chain.call({ - query, - signal, - }); - return result.response; - }, - }); -} - -module.exports = { - getSpec, - readSpecFile, - createOpenAPIPlugin, -}; diff --git a/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js b/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js deleted file mode 100644 index 83bc5e939..000000000 --- a/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -const fs = require('fs'); -const { createOpenAPIPlugin, getSpec, readSpecFile } = require('./OpenAPIPlugin'); - -global.fetch = jest.fn().mockImplementationOnce(() => { - return new Promise((resolve) => { - resolve({ - ok: true, - json: () => Promise.resolve({ key: 'value' }), - }); - }); -}); -jest.mock('fs', () => ({ - promises: { - readFile: jest.fn(), - }, - existsSync: jest.fn(), -})); - -describe('readSpecFile', () => { - it('reads JSON file correctly', async () => { - fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' })); - const result = await readSpecFile('test.json'); - expect(result).toEqual({ test: 'value' }); - }); - - it('reads YAML file correctly', async () => { - fs.promises.readFile.mockResolvedValue('test: value'); - const result = await readSpecFile('test.yaml'); - expect(result).toEqual({ test: 'value' }); - }); - - it('handles error correctly', async () => { - fs.promises.readFile.mockRejectedValue(new Error('test error')); - const result = await readSpecFile('test.json'); - expect(result).toBe(false); - }); -}); - -describe('getSpec', () => { - it('fetches spec from url correctly', async () => { - const parsedJson = await getSpec('https://www.instacart.com/.well-known/ai-plugin.json'); - const isObject = typeof parsedJson === 'object'; - expect(isObject).toEqual(true); - }); - - it('reads spec from file correctly', async () => { - fs.existsSync.mockReturnValue(true); - fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' })); - const result = await getSpec('test.json'); - expect(result).toEqual({ test: 'value' }); - }); - - it('returns false when file does not exist', async () => { - fs.existsSync.mockReturnValue(false); - const result = await getSpec('test.json'); - expect(result).toBe(false); - }); -}); - -describe('createOpenAPIPlugin', () => { - it('returns null when getSpec throws an error', async () => { - const result = await createOpenAPIPlugin({ data: { api: { url: 'invalid' } } }); - expect(result).toBe(null); - }); - - it('returns null when no spec is found', async () => { - const result = await createOpenAPIPlugin({}); - expect(result).toBe(null); - }); - - // Add more tests here for different scenarios -}); diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index fc0f1851f..7c2a56fe7 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -8,10 +8,10 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); -const { logger } = require('~/config'); +const logger = require('~/config/winston'); const displayMessage = - 'DALL-E displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + "DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; class DALLE3 extends Tool { constructor(fields = {}) { super(); diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js index 1b28de2fa..2def575fb 100644 --- a/api/app/clients/tools/structured/specs/DALLE3.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js @@ -1,10 +1,29 @@ const OpenAI = require('openai'); const DALLE3 = require('../DALLE3'); - -const { logger } = require('~/config'); +const logger = require('~/config/winston'); jest.mock('openai'); +jest.mock('@librechat/data-schemas', () => { + return { + logger: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + }; +}); + +jest.mock('tiktoken', () => { + return { + encoding_for_model: jest.fn().mockReturnValue({ + encode: jest.fn(), + decode: jest.fn(), + }), + }; +}); + const processFileURL = jest.fn(); jest.mock('~/server/services/Files/images', () => ({ @@ -37,6 +56,11 @@ jest.mock('fs', () => { return { existsSync: jest.fn(), mkdirSync: jest.fn(), + promises: { + writeFile: jest.fn(), + readFile: jest.fn(), + unlink: jest.fn(), + }, }; }); diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 54da48336..19d3a79ed 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -135,7 +135,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => { query: z .string() .describe( - 'A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you\'re looking for. The query will be used for semantic similarity matching against the file contents.', + "A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.", ), }), }, diff --git a/api/config/index.js b/api/config/index.js index e238f700b..8f24581be 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,7 +1,7 @@ const axios = require('axios'); const { EventSource } = require('eventsource'); -const { Time, CacheKeys } = require('librechat-data-provider'); -const { MCPManager, FlowStateManager } = require('librechat-mcp'); +const { Time } = require('librechat-data-provider'); +const { MCPManager, FlowStateManager } = require('@librechat/api'); const logger = require('./winston'); global.EventSource = EventSource; diff --git a/api/package.json b/api/package.json index 6646c3a49..22be4f18e 100644 --- a/api/package.json +++ b/api/package.json @@ -49,6 +49,7 @@ "@langchain/google-vertexai": "^0.2.9", "@langchain/textsplitters": "^0.1.0", "@librechat/agents": "^2.4.38", + "@librechat/api": "*", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", "@waylaidwanderer/fetch-event-source": "^3.0.1", @@ -81,7 +82,6 @@ "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", - "librechat-mcp": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", @@ -90,6 +90,7 @@ "mongoose": "^8.12.1", "multer": "^2.0.0", "nanoid": "^3.3.7", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.15", "ollama": "^0.5.0", "openai": "^4.96.2", @@ -110,7 +111,7 @@ "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1", + "winston-daily-rotate-file": "^5.0.0", "youtube-transcript": "^1.2.1", "zod": "^3.22.4" }, diff --git a/api/server/cleanup.js b/api/server/cleanup.js index 5bf336eed..de7450cea 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -220,6 +220,9 @@ function disposeClient(client) { if (client.maxResponseTokens) { client.maxResponseTokens = null; } + if (client.processMemory) { + client.processMemory = null; + } if (client.run) { // Break circular references in run if (client.run.Graph) { diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index cedfc6bd6..60e68b5f2 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -1,4 +1,6 @@ const { nanoid } = require('nanoid'); +const { sendEvent } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { Tools, StepTypes, FileContext } = require('librechat-data-provider'); const { EnvVar, @@ -12,7 +14,6 @@ const { const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { saveBase64Image } = require('~/server/services/Files/process'); -const { logger, sendEvent } = require('~/config'); class ModelEndHandler { /** @@ -240,9 +241,7 @@ function createToolEndCallback({ req, res, artifactPromises }) { if (output.artifact[Tools.web_search]) { artifactPromises.push( (async () => { - const name = `${output.name}_${output.tool_call_id}_${nanoid()}`; const attachment = { - name, type: Tools.web_search, messageId: metadata.run_id, toolCallId: output.tool_call_id, diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9631fe380..7c8a375a9 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1,13 +1,12 @@ -// const { HttpsProxyAgent } = require('https-proxy-agent'); -// const { -// Constants, -// ImageDetail, -// EModelEndpoint, -// resolveHeaders, -// validateVisionModel, -// mapModelToAzureConfig, -// } = require('librechat-data-provider'); require('events').EventEmitter.defaultMaxListeners = 100; +const { logger } = require('@librechat/data-schemas'); +const { + sendEvent, + createRun, + Tokenizer, + memoryInstructions, + createMemoryProcessor, +} = require('@librechat/api'); const { Callback, GraphEvents, @@ -19,26 +18,30 @@ const { } = require('@librechat/agents'); const { Constants, + Permissions, VisionModes, ContentTypes, EModelEndpoint, KnownEndpoints, + PermissionTypes, isAgentsEndpoint, AgentCapabilities, bedrockInputSchema, removeNullishValues, } = require('librechat-data-provider'); +const { DynamicStructuredTool } = require('@langchain/core/tools'); +const { getBufferString, HumanMessage } = require('@langchain/core/messages'); const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config'); const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); +const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); -const { getBufferString, HumanMessage } = require('@langchain/core/messages'); -const { DynamicStructuredTool } = require('@langchain/core/tools'); +const { setMemory, deleteMemory, getFormattedMemories } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); -const Tokenizer = require('~/server/services/Tokenizer'); +const { checkAccess } = require('~/server/middleware/roles/access'); const BaseClient = require('~/app/clients/BaseClient'); -const { logger, sendEvent, getMCPManager } = require('~/config'); -const { createRun } = require('./run'); +const { loadAgent } = require('~/models/Agent'); +const { getMCPManager } = require('~/config'); /** * @param {ServerRequest} req @@ -58,12 +61,8 @@ const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deep const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi]; -// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory'); -// const { getFormattedMemories } = require('~/models/Memory'); -// const { getCurrentDateTime } = require('~/utils'); - function createTokenCounter(encoding) { - return (message) => { + return function (message) { const countTokens = (text) => Tokenizer.getTokenCount(text, encoding); return getTokenCountForMessage(message, countTokens); }; @@ -124,6 +123,8 @@ class AgentClient extends BaseClient { this.usage; /** @type {Record} */ this.indexTokenCountMap = {}; + /** @type {(messages: BaseMessage[]) => Promise} */ + this.processMemory; } /** @@ -138,55 +139,10 @@ class AgentClient extends BaseClient { } /** - * - * Checks if the model is a vision model based on request attachments and sets the appropriate options: - * - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request. - * - Sets `this.isVisionModel` to `true` if vision request. - * - Deletes `this.modelOptions.stop` if vision request. + * `AgentClient` is not opinionated about vision requests, so we don't do anything here * @param {MongoFile[]} attachments */ - checkVisionRequest(attachments) { - // if (!attachments) { - // return; - // } - // const availableModels = this.options.modelsConfig?.[this.options.endpoint]; - // if (!availableModels) { - // return; - // } - // let visionRequestDetected = false; - // for (const file of attachments) { - // if (file?.type?.includes('image')) { - // visionRequestDetected = true; - // break; - // } - // } - // if (!visionRequestDetected) { - // return; - // } - // this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels }); - // if (this.isVisionModel) { - // delete this.modelOptions.stop; - // return; - // } - // for (const model of availableModels) { - // if (!validateVisionModel({ model, availableModels })) { - // continue; - // } - // this.modelOptions.model = model; - // this.isVisionModel = true; - // delete this.modelOptions.stop; - // return; - // } - // if (!availableModels.includes(this.defaultVisionModel)) { - // return; - // } - // if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) { - // return; - // } - // this.modelOptions.model = this.defaultVisionModel; - // this.isVisionModel = true; - // delete this.modelOptions.stop; - } + checkVisionRequest() {} getSaveOptions() { // TODO: @@ -270,24 +226,6 @@ class AgentClient extends BaseClient { .filter(Boolean) .join('\n') .trim(); - // this.systemMessage = getCurrentDateTime(); - // const { withKeys, withoutKeys } = await getFormattedMemories({ - // userId: this.options.req.user.id, - // }); - // processMemory({ - // userId: this.options.req.user.id, - // message: this.options.req.body.text, - // parentMessageId, - // memory: withKeys, - // thread_id: this.conversationId, - // }).catch((error) => { - // logger.error('Memory Agent failed to process memory', error); - // }); - - // this.systemMessage += '\n\n' + memoryInstructions; - // if (withoutKeys) { - // this.systemMessage += `\n\n# Existing memory about the user:\n${withoutKeys}`; - // } if (this.options.attachments) { const attachments = await this.options.attachments; @@ -431,9 +369,150 @@ class AgentClient extends BaseClient { opts.getReqData({ promptTokens }); } + const withoutKeys = await this.useMemory(); + if (withoutKeys) { + systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; + } + + if (systemContent) { + this.options.agent.instructions = systemContent; + } + return result; } + /** + * @returns {Promise} + */ + async useMemory() { + const user = this.options.req.user; + if (user.personalization?.memories === false) { + return; + } + const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]); + + if (!hasAccess) { + logger.debug( + `[api/server/controllers/agents/client.js #useMemory] User ${user.id} does not have USE permission for memories`, + ); + return; + } + /** @type {TCustomConfig['memory']} */ + const memoryConfig = this.options.req?.app?.locals?.memory; + if (!memoryConfig || memoryConfig.disabled === true) { + return; + } + + /** @type {Agent} */ + let prelimAgent; + const allowedProviders = new Set( + this.options.req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders, + ); + try { + if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) { + prelimAgent = await loadAgent({ + req: this.options.req, + agent_id: memoryConfig.agent.id, + endpoint: EModelEndpoint.agents, + }); + } else if ( + memoryConfig.agent?.id == null && + memoryConfig.agent?.model != null && + memoryConfig.agent?.provider != null + ) { + prelimAgent = { id: Constants.EPHEMERAL_AGENT_ID, ...memoryConfig.agent }; + } + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #useMemory] Error loading agent for memory', + error, + ); + } + + const agent = await initializeAgent({ + req: this.options.req, + res: this.options.res, + agent: prelimAgent, + allowedProviders, + }); + + if (!agent) { + logger.warn( + '[api/server/controllers/agents/client.js #useMemory] No agent found for memory', + memoryConfig, + ); + return; + } + + const llmConfig = Object.assign( + { + provider: agent.provider, + model: agent.model, + }, + agent.model_parameters, + ); + + /** @type {import('@librechat/api').MemoryConfig} */ + const config = { + validKeys: memoryConfig.validKeys, + instructions: agent.instructions, + llmConfig, + tokenLimit: memoryConfig.tokenLimit, + }; + + const userId = this.options.req.user.id + ''; + const messageId = this.responseMessageId + ''; + const conversationId = this.conversationId + ''; + const [withoutKeys, processMemory] = await createMemoryProcessor({ + userId, + config, + messageId, + conversationId, + memoryMethods: { + setMemory, + deleteMemory, + getFormattedMemories, + }, + res: this.options.res, + }); + + this.processMemory = processMemory; + return withoutKeys; + } + + /** + * @param {BaseMessage[]} messages + * @returns {Promise} + */ + async runMemory(messages) { + try { + if (this.processMemory == null) { + return; + } + /** @type {TCustomConfig['memory']} */ + const memoryConfig = this.options.req?.app?.locals?.memory; + const messageWindowSize = memoryConfig?.messageWindowSize ?? 5; + + let messagesToProcess = [...messages]; + if (messages.length > messageWindowSize) { + for (let i = messages.length - messageWindowSize; i >= 0; i--) { + const potentialWindow = messages.slice(i, i + messageWindowSize); + if (potentialWindow[0]?.role === 'user') { + messagesToProcess = [...potentialWindow]; + break; + } + } + + if (messagesToProcess.length === messages.length) { + messagesToProcess = [...messages.slice(-messageWindowSize)]; + } + } + return await this.processMemory(messagesToProcess); + } catch (error) { + logger.error('Memory Agent failed to process memory', error); + } + } + /** @type {sendCompletion} */ async sendCompletion(payload, opts = {}) { await this.chatCompletion({ @@ -576,100 +655,13 @@ class AgentClient extends BaseClient { let config; /** @type {ReturnType} */ let run; + /** @type {Promise<(TAttachment | null)[] | undefined>} */ + let memoryPromise; try { if (!abortController) { abortController = new AbortController(); } - // if (this.options.headers) { - // opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers }; - // } - - // if (this.options.proxy) { - // opts.httpAgent = new HttpsProxyAgent(this.options.proxy); - // } - - // if (this.isVisionModel) { - // modelOptions.max_tokens = 4000; - // } - - // /** @type {TAzureConfig | undefined} */ - // const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI]; - - // if ( - // (this.azure && this.isVisionModel && azureConfig) || - // (azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI) - // ) { - // const { modelGroupMap, groupMap } = azureConfig; - // const { - // azureOptions, - // baseURL, - // headers = {}, - // serverless, - // } = mapModelToAzureConfig({ - // modelName: modelOptions.model, - // modelGroupMap, - // groupMap, - // }); - // opts.defaultHeaders = resolveHeaders(headers); - // this.langchainProxy = extractBaseURL(baseURL); - // this.apiKey = azureOptions.azureOpenAIApiKey; - - // const groupName = modelGroupMap[modelOptions.model].group; - // this.options.addParams = azureConfig.groupMap[groupName].addParams; - // this.options.dropParams = azureConfig.groupMap[groupName].dropParams; - // // Note: `forcePrompt` not re-assigned as only chat models are vision models - - // this.azure = !serverless && azureOptions; - // this.azureEndpoint = - // !serverless && genAzureChatCompletion(this.azure, modelOptions.model, this); - // } - - // if (this.azure || this.options.azure) { - // /* Azure Bug, extremely short default `max_tokens` response */ - // if (!modelOptions.max_tokens && modelOptions.model === 'gpt-4-vision-preview') { - // modelOptions.max_tokens = 4000; - // } - - // /* Azure does not accept `model` in the body, so we need to remove it. */ - // delete modelOptions.model; - - // opts.baseURL = this.langchainProxy - // ? constructAzureURL({ - // baseURL: this.langchainProxy, - // azureOptions: this.azure, - // }) - // : this.azureEndpoint.split(/(? { - // delete modelOptions[param]; - // }); - // logger.debug('[api/server/controllers/agents/client.js #chatCompletion] dropped params', { - // dropParams: this.options.dropParams, - // modelOptions, - // }); - // } - /** @type {TCustomConfig['endpoints']['agents']} */ const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents]; @@ -766,6 +758,10 @@ class AgentClient extends BaseClient { messages = addCacheControl(messages); } + if (i === 0) { + memoryPromise = this.runMemory(messages); + } + run = await createRun({ agent, req: this.options.req, @@ -801,10 +797,9 @@ class AgentClient extends BaseClient { run.Graph.contentData = contentData; } - const encoding = this.getEncoding(); await run.processStream({ messages }, config, { keepContent: i !== 0, - tokenCounter: createTokenCounter(encoding), + tokenCounter: createTokenCounter(this.getEncoding()), indexTokenCountMap: currentIndexCountMap, maxContextTokens: agent.maxContextTokens, callbacks: { @@ -919,6 +914,12 @@ class AgentClient extends BaseClient { }); try { + if (memoryPromise) { + const attachments = await memoryPromise; + if (attachments && attachments.length > 0) { + this.artifactPromises.push(...attachments); + } + } await this.recordCollectedUsage({ context: 'message' }); } catch (err) { logger.error( @@ -927,6 +928,12 @@ class AgentClient extends BaseClient { ); } } catch (err) { + if (memoryPromise) { + const attachments = await memoryPromise; + if (attachments && attachments.length > 0) { + this.artifactPromises.push(...attachments); + } + } logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', err, diff --git a/api/server/controllers/agents/run.js b/api/server/controllers/agents/run.js deleted file mode 100644 index 2452e6623..000000000 --- a/api/server/controllers/agents/run.js +++ /dev/null @@ -1,94 +0,0 @@ -const { Run, Providers } = require('@librechat/agents'); -const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider'); - -/** - * @typedef {import('@librechat/agents').t} t - * @typedef {import('@librechat/agents').StandardGraphConfig} StandardGraphConfig - * @typedef {import('@librechat/agents').StreamEventData} StreamEventData - * @typedef {import('@librechat/agents').EventHandler} EventHandler - * @typedef {import('@librechat/agents').GraphEvents} GraphEvents - * @typedef {import('@librechat/agents').LLMConfig} LLMConfig - * @typedef {import('@librechat/agents').IState} IState - */ - -const customProviders = new Set([ - Providers.XAI, - Providers.OLLAMA, - Providers.DEEPSEEK, - Providers.OPENROUTER, -]); - -/** - * Creates a new Run instance with custom handlers and configuration. - * - * @param {Object} options - The options for creating the Run instance. - * @param {ServerRequest} [options.req] - The server request. - * @param {string | undefined} [options.runId] - Optional run ID; otherwise, a new run ID will be generated. - * @param {Agent} options.agent - The agent for this run. - * @param {AbortSignal} options.signal - The signal for this run. - * @param {Record | undefined} [options.customHandlers] - Custom event handlers. - * @param {boolean} [options.streaming=true] - Whether to use streaming. - * @param {boolean} [options.streamUsage=true] - Whether to stream usage information. - * @returns {Promise>} A promise that resolves to a new Run instance. - */ -async function createRun({ - runId, - agent, - signal, - customHandlers, - streaming = true, - streamUsage = true, -}) { - const provider = providerEndpointMap[agent.provider] ?? agent.provider; - /** @type {LLMConfig} */ - const llmConfig = Object.assign( - { - provider, - streaming, - streamUsage, - }, - agent.model_parameters, - ); - - /** Resolves issues with new OpenAI usage field */ - if ( - customProviders.has(agent.provider) || - (agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider) - ) { - llmConfig.streamUsage = false; - llmConfig.usage = true; - } - - /** @type {'reasoning_content' | 'reasoning'} */ - let reasoningKey; - if ( - llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter) || - (agent.endpoint && agent.endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) - ) { - reasoningKey = 'reasoning'; - } - - /** @type {StandardGraphConfig} */ - const graphConfig = { - signal, - llmConfig, - reasoningKey, - tools: agent.tools, - instructions: agent.instructions, - additional_instructions: agent.additional_instructions, - // toolEnd: agent.end_after_tools, - }; - - // TEMPORARY FOR TESTING - if (agent.provider === Providers.ANTHROPIC || agent.provider === Providers.BEDROCK) { - graphConfig.streamBuffer = 2000; - } - - return Run.create({ - runId, - graphConfig, - customHandlers, - }); -} - -module.exports = { createRun }; diff --git a/api/server/index.js b/api/server/index.js index ed770f770..a04c339b0 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -117,7 +117,7 @@ const startServer = async () => { app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); app.use('/api/bedrock', routes.bedrock); - + app.use('/api/memories', routes.memories); app.use('/api/tags', routes.tags); app.use((req, res) => { diff --git a/api/server/middleware/roles/generateCheckAccess.js b/api/server/middleware/roles/access.js similarity index 100% rename from api/server/middleware/roles/generateCheckAccess.js rename to api/server/middleware/roles/access.js diff --git a/api/server/middleware/roles/checkAdmin.js b/api/server/middleware/roles/admin.js similarity index 100% rename from api/server/middleware/roles/checkAdmin.js rename to api/server/middleware/roles/admin.js diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js index a9fc5b2a0..ebc0043f2 100644 --- a/api/server/middleware/roles/index.js +++ b/api/server/middleware/roles/index.js @@ -1,5 +1,5 @@ -const checkAdmin = require('./checkAdmin'); -const { checkAccess, generateCheckAccess } = require('./generateCheckAccess'); +const checkAdmin = require('./admin'); +const { checkAccess, generateCheckAccess } = require('./access'); module.exports = { checkAdmin, diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index f23ecd282..257c309fa 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -2,8 +2,8 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); +const { sanitizeFilename } = require('@librechat/api'); const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); -const { sanitizeFilename } = require('~/server/utils/handleText'); const { getCustomConfig } = require('~/server/services/Config'); const storage = multer.diskStorage({ diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 449759383..06e39d367 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -4,6 +4,7 @@ const tokenizer = require('./tokenizer'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); +const memories = require('./memories'); const presets = require('./presets'); const prompts = require('./prompts'); const balance = require('./balance'); @@ -51,6 +52,7 @@ module.exports = { presets, balance, messages, + memories, endpoints, tokenizer, assistants, diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js new file mode 100644 index 000000000..86065feca --- /dev/null +++ b/api/server/routes/memories.js @@ -0,0 +1,231 @@ +const express = require('express'); +const { Tokenizer } = require('@librechat/api'); +const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + getAllUserMemories, + toggleUserMemories, + createMemory, + setMemory, + deleteMemory, +} = require('~/models'); +const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); + +const router = express.Router(); + +const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.READ, +]); +const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.CREATE, +]); +const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.UPDATE, +]); +const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.UPDATE, +]); +const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.OPT_OUT, +]); + +router.use(requireJwtAuth); + +/** + * GET /memories + * Returns all memories for the authenticated user, sorted by updated_at (newest first). + * Also includes memory usage percentage based on token limit. + */ +router.get('/', checkMemoryRead, async (req, res) => { + try { + const memories = await getAllUserMemories(req.user.id); + + const sortedMemories = memories.sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + + const totalTokens = memories.reduce((sum, memory) => { + return sum + (memory.tokenCount || 0); + }, 0); + + const memoryConfig = req.app.locals?.memory; + const tokenLimit = memoryConfig?.tokenLimit; + + let usagePercentage = null; + if (tokenLimit && tokenLimit > 0) { + usagePercentage = Math.min(100, Math.round((totalTokens / tokenLimit) * 100)); + } + + res.json({ + memories: sortedMemories, + totalTokens, + tokenLimit: tokenLimit || null, + usagePercentage, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /memories + * Creates a new memory entry for the authenticated user. + * Body: { key: string, value: string } + * Returns 201 and { created: true, memory: } when successful. + */ +router.post('/', checkMemoryCreate, async (req, res) => { + const { key, value } = req.body; + + if (typeof key !== 'string' || key.trim() === '') { + return res.status(400).json({ error: 'Key is required and must be a non-empty string.' }); + } + + if (typeof value !== 'string' || value.trim() === '') { + return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); + } + + try { + const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); + + const memories = await getAllUserMemories(req.user.id); + + // Check token limit + const memoryConfig = req.app.locals?.memory; + const tokenLimit = memoryConfig?.tokenLimit; + + if (tokenLimit) { + const currentTotalTokens = memories.reduce( + (sum, memory) => sum + (memory.tokenCount || 0), + 0, + ); + if (currentTotalTokens + tokenCount > tokenLimit) { + return res.status(400).json({ + error: `Adding this memory would exceed the token limit of ${tokenLimit}. Current usage: ${currentTotalTokens} tokens.`, + }); + } + } + + const result = await createMemory({ + userId: req.user.id, + key: key.trim(), + value: value.trim(), + tokenCount, + }); + + if (!result.ok) { + return res.status(500).json({ error: 'Failed to create memory.' }); + } + + const updatedMemories = await getAllUserMemories(req.user.id); + const newMemory = updatedMemories.find((m) => m.key === key.trim()); + + res.status(201).json({ created: true, memory: newMemory }); + } catch (error) { + if (error.message && error.message.includes('already exists')) { + return res.status(409).json({ error: 'Memory with this key already exists.' }); + } + res.status(500).json({ error: error.message }); + } +}); + +/** + * PATCH /memories/preferences + * Updates the user's memory preferences (e.g., enabling/disabling memories). + * Body: { memories: boolean } + * Returns 200 and { updated: true, preferences: { memories: boolean } } when successful. + */ +router.patch('/preferences', checkMemoryOptOut, async (req, res) => { + const { memories } = req.body; + + if (typeof memories !== 'boolean') { + return res.status(400).json({ error: 'memories must be a boolean value.' }); + } + + try { + const updatedUser = await toggleUserMemories(req.user.id, memories); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found.' }); + } + + res.json({ + updated: true, + preferences: { + memories: updatedUser.personalization?.memories ?? true, + }, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * PATCH /memories/:key + * Updates the value of an existing memory entry for the authenticated user. + * Body: { value: string } + * Returns 200 and { updated: true, memory: } when successful. + */ +router.patch('/:key', checkMemoryUpdate, async (req, res) => { + const { key } = req.params; + const { value } = req.body || {}; + + if (typeof value !== 'string' || value.trim() === '') { + return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); + } + + try { + const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); + + const memories = await getAllUserMemories(req.user.id); + const existingMemory = memories.find((m) => m.key === key); + + if (!existingMemory) { + return res.status(404).json({ error: 'Memory not found.' }); + } + + const result = await setMemory({ + userId: req.user.id, + key, + value, + tokenCount, + }); + + if (!result.ok) { + return res.status(500).json({ error: 'Failed to update memory.' }); + } + + const updatedMemories = await getAllUserMemories(req.user.id); + const updatedMemory = updatedMemories.find((m) => m.key === key); + + res.json({ updated: true, memory: updatedMemory }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * DELETE /memories/:key + * Deletes a memory entry for the authenticated user. + * Returns 200 and { deleted: true } when successful. + */ +router.delete('/:key', checkMemoryDelete, async (req, res) => { + const { key } = req.params; + + try { + const result = await deleteMemory({ userId: req.user.id, key }); + + if (!result.ok) { + return res.status(404).json({ error: 'Memory not found.' }); + } + + res.json({ deleted: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index 17768c7de..aefbfcec0 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -1,6 +1,7 @@ const express = require('express'); const { promptPermissionsSchema, + memoryPermissionsSchema, agentPermissionsSchema, PermissionTypes, roleDefaults, @@ -118,4 +119,43 @@ router.put('/:roleName/agents', checkAdmin, async (req, res) => { } }); +/** + * PUT /api/roles/:roleName/memories + * Update memory permissions for a specific role + */ +router.put('/:roleName/memories', checkAdmin, async (req, res) => { + const { roleName: _r } = req.params; + // TODO: TEMP, use a better parsing for roleName + const roleName = _r.toUpperCase(); + /** @type {TRole['permissions']['MEMORIES']} */ + const updates = req.body; + + try { + const parsedUpdates = memoryPermissionsSchema.partial().parse(updates); + + const role = await getRoleByName(roleName); + if (!role) { + return res.status(404).send({ message: 'Role not found' }); + } + + const currentPermissions = + role.permissions?.[PermissionTypes.MEMORIES] || role[PermissionTypes.MEMORIES] || {}; + + const mergedUpdates = { + permissions: { + ...role.permissions, + [PermissionTypes.MEMORIES]: { + ...currentPermissions, + ...parsedUpdates, + }, + }, + }; + + const updatedRole = await updateRoleByName(roleName, mergedUpdates); + res.status(200).send(updatedRole); + } catch (error) { + return res.status(400).send({ message: 'Invalid memory permissions.', error: error.errors }); + } +}); + module.exports = router; diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index a35c74ad7..ed9b0c447 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -1,6 +1,8 @@ const jwt = require('jsonwebtoken'); const { nanoid } = require('nanoid'); +const { sendEvent } = require('@librechat/api'); const { tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { GraphEvents, sleep } = require('@librechat/agents'); const { Time, @@ -13,10 +15,10 @@ const { actionDomainSeparator, } = require('librechat-data-provider'); const { refreshAccessToken } = require('~/server/services/TokenService'); -const { logger, getFlowStateManager, sendEvent } = require('~/config'); const { encryptV2, decryptV2 } = require('~/server/utils/crypto'); const { getActions, deleteActions } = require('~/models/Action'); const { deleteAssistant } = require('~/models/Assistant'); +const { getFlowStateManager } = require('~/config'); const { logAxiosError } = require('~/utils'); const { getLogStores } = require('~/cache'); const { findToken } = require('~/models'); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 4bb8c51d0..dc686d629 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -3,6 +3,7 @@ const { loadOCRConfig, processMCPEnv, EModelEndpoint, + loadMemoryConfig, getConfigDefaults, loadWebSearchConfig, } = require('librechat-data-provider'); @@ -44,6 +45,7 @@ const AppService = async (app) => { const ocr = loadOCRConfig(config.ocr); const webSearch = loadWebSearchConfig(config.webSearch); checkWebSearchConfig(webSearch); + const memory = loadMemoryConfig(config.memory); const filteredTools = config.filteredTools; const includedTools = config.includedTools; const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; @@ -88,6 +90,7 @@ const AppService = async (app) => { const defaultLocals = { ocr, paths, + memory, webSearch, fileStrategy, socialLogins, diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js new file mode 100644 index 000000000..13a42140d --- /dev/null +++ b/api/server/services/Endpoints/agents/agent.js @@ -0,0 +1,196 @@ +const { Providers } = require('@librechat/agents'); +const { primeResources, optionalChainWithEmptyCheck } = require('@librechat/api'); +const { + ErrorTypes, + EModelEndpoint, + EToolResources, + replaceSpecialVars, + providerEndpointMap, +} = require('librechat-data-provider'); +const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); +const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); +const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); +const initCustom = require('~/server/services/Endpoints/custom/initialize'); +const initGoogle = require('~/server/services/Endpoints/google/initialize'); +const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); +const { getCustomEndpointConfig } = require('~/server/services/Config'); +const { processFiles } = require('~/server/services/Files/process'); +const { getConvoFiles } = require('~/models/Conversation'); +const { getToolFilesByIds } = require('~/models/File'); +const { getModelMaxTokens } = require('~/utils'); +const { getFiles } = require('~/models/File'); + +const providerConfigMap = { + [Providers.XAI]: initCustom, + [Providers.OLLAMA]: initCustom, + [Providers.DEEPSEEK]: initCustom, + [Providers.OPENROUTER]: initCustom, + [EModelEndpoint.openAI]: initOpenAI, + [EModelEndpoint.google]: initGoogle, + [EModelEndpoint.azureOpenAI]: initOpenAI, + [EModelEndpoint.anthropic]: initAnthropic, + [EModelEndpoint.bedrock]: getBedrockOptions, +}; + +/** + * @param {object} params + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res + * @param {Agent} params.agent + * @param {string | null} [params.conversationId] + * @param {Array} [params.requestFiles] + * @param {typeof import('~/server/services/ToolService').loadAgentTools | undefined} [params.loadTools] + * @param {TEndpointOption} [params.endpointOption] + * @param {Set} [params.allowedProviders] + * @param {boolean} [params.isInitialAgent] + * @returns {Promise, toolContextMap: Record, maxContextTokens: number }>} + */ +const initializeAgent = async ({ + req, + res, + agent, + loadTools, + requestFiles, + conversationId, + endpointOption, + allowedProviders, + isInitialAgent = false, +}) => { + if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) { + throw new Error( + `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`, + ); + } + let currentFiles; + + if ( + isInitialAgent && + conversationId != null && + (agent.model_parameters?.resendFiles ?? true) === true + ) { + const fileIds = (await getConvoFiles(conversationId)) ?? []; + /** @type {Set} */ + const toolResourceSet = new Set(); + for (const tool of agent.tools) { + if (EToolResources[tool]) { + toolResourceSet.add(EToolResources[tool]); + } + } + const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet); + if (requestFiles.length || toolFiles.length) { + currentFiles = await processFiles(requestFiles.concat(toolFiles)); + } + } else if (isInitialAgent && requestFiles.length) { + currentFiles = await processFiles(requestFiles); + } + + const { attachments, tool_resources } = await primeResources({ + req, + getFiles, + attachments: currentFiles, + tool_resources: agent.tool_resources, + requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), + }); + + const provider = agent.provider; + const { tools, toolContextMap } = + (await loadTools?.({ + req, + res, + provider, + agentId: agent.id, + tools: agent.tools, + model: agent.model, + tool_resources, + })) ?? {}; + + agent.endpoint = provider; + let getOptions = providerConfigMap[provider]; + if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { + agent.provider = provider.toLowerCase(); + getOptions = providerConfigMap[agent.provider]; + } else if (!getOptions) { + const customEndpointConfig = await getCustomEndpointConfig(provider); + if (!customEndpointConfig) { + throw new Error(`Provider ${provider} not supported`); + } + getOptions = initCustom; + agent.provider = Providers.OPENAI; + } + const model_parameters = Object.assign( + {}, + agent.model_parameters ?? { model: agent.model }, + isInitialAgent === true ? endpointOption?.model_parameters : {}, + ); + const _endpointOption = + isInitialAgent === true + ? Object.assign({}, endpointOption, { model_parameters }) + : { model_parameters }; + + const options = await getOptions({ + req, + res, + optionsOnly: true, + overrideEndpoint: provider, + overrideModel: agent.model, + endpointOption: _endpointOption, + }); + + if ( + agent.endpoint === EModelEndpoint.azureOpenAI && + options.llmConfig?.azureOpenAIApiInstanceName == null + ) { + agent.provider = Providers.OPENAI; + } + + if (options.provider != null) { + agent.provider = options.provider; + } + + /** @type {import('@librechat/agents').ClientOptions} */ + agent.model_parameters = Object.assign(model_parameters, options.llmConfig); + if (options.configOptions) { + agent.model_parameters.configuration = options.configOptions; + } + + if (!agent.model_parameters.model) { + agent.model_parameters.model = agent.model; + } + + if (agent.instructions && agent.instructions !== '') { + agent.instructions = replaceSpecialVars({ + text: agent.instructions, + user: req.user, + }); + } + + if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { + agent.additional_instructions = generateArtifactsPrompt({ + endpoint: agent.provider, + artifacts: agent.artifacts, + }); + } + + const tokensModel = + agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model; + const maxTokens = optionalChainWithEmptyCheck( + agent.model_parameters.maxOutputTokens, + agent.model_parameters.maxTokens, + 0, + ); + const maxContextTokens = optionalChainWithEmptyCheck( + agent.model_parameters.maxContextTokens, + agent.max_context_tokens, + getModelMaxTokens(tokensModel, providerEndpointMap[provider]), + 4096, + ); + return { + ...agent, + tools, + attachments, + toolContextMap, + maxContextTokens: (maxContextTokens - maxTokens) * 0.9, + }; +}; + +module.exports = { initializeAgent }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index c9e363e81..e3154fe13 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,294 +1,41 @@ -const { createContentAggregator, Providers } = require('@librechat/agents'); -const { - Constants, - ErrorTypes, - EModelEndpoint, - EToolResources, - getResponseSender, - AgentCapabilities, - replaceSpecialVars, - providerEndpointMap, -} = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const { createContentAggregator } = require('@librechat/agents'); +const { Constants, EModelEndpoint, getResponseSender } = require('librechat-data-provider'); const { getDefaultHandlers, createToolEndCallback, } = require('~/server/controllers/agents/callbacks'); -const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); -const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); -const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); -const initCustom = require('~/server/services/Endpoints/custom/initialize'); -const initGoogle = require('~/server/services/Endpoints/google/initialize'); -const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); -const { getCustomEndpointConfig } = require('~/server/services/Config'); -const { processFiles } = require('~/server/services/Files/process'); +const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); -const { getConvoFiles } = require('~/models/Conversation'); -const { getToolFilesByIds } = require('~/models/File'); -const { getModelMaxTokens } = require('~/utils'); const { getAgent } = require('~/models/Agent'); -const { getFiles } = require('~/models/File'); -const { logger } = require('~/config'); -const providerConfigMap = { - [Providers.XAI]: initCustom, - [Providers.OLLAMA]: initCustom, - [Providers.DEEPSEEK]: initCustom, - [Providers.OPENROUTER]: initCustom, - [EModelEndpoint.openAI]: initOpenAI, - [EModelEndpoint.google]: initGoogle, - [EModelEndpoint.azureOpenAI]: initOpenAI, - [EModelEndpoint.anthropic]: initAnthropic, - [EModelEndpoint.bedrock]: getBedrockOptions, -}; - -/** - * @param {Object} params - * @param {ServerRequest} params.req - * @param {Promise> | undefined} [params.attachments] - * @param {Set} params.requestFileSet - * @param {AgentToolResources | undefined} [params.tool_resources] - * @returns {Promise<{ attachments: Array | undefined, tool_resources: AgentToolResources | undefined }>} - */ -const primeResources = async ({ - req, - attachments: _attachments, - tool_resources: _tool_resources, - requestFileSet, -}) => { - try { - /** @type {Array | undefined} */ - let attachments; - const tool_resources = _tool_resources ?? {}; - const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes( - AgentCapabilities.ocr, - ); - if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) { - const context = await getFiles( - { - file_id: { $in: tool_resources.ocr.file_ids }, - }, - {}, - {}, - ); - attachments = (attachments ?? []).concat(context); +function createToolLoader() { + /** + * @param {object} params + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res + * @param {string} params.agentId + * @param {string[]} params.tools + * @param {string} params.provider + * @param {string} params.model + * @param {AgentToolResources} params.tool_resources + * @returns {Promise<{ tools: StructuredTool[], toolContextMap: Record } | undefined>} + */ + return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) { + const agent = { id: agentId, tools, provider, model }; + try { + return await loadAgentTools({ + req, + res, + agent, + tool_resources, + }); + } catch (error) { + logger.error('Error loading tools for agent ' + agentId, error); } - if (!_attachments) { - return { attachments, tool_resources }; - } - /** @type {Array | undefined} */ - const files = await _attachments; - if (!attachments) { - /** @type {Array} */ - attachments = []; - } - - for (const file of files) { - if (!file) { - continue; - } - if (file.metadata?.fileIdentifier) { - const execute_code = tool_resources[EToolResources.execute_code] ?? {}; - if (!execute_code.files) { - tool_resources[EToolResources.execute_code] = { ...execute_code, files: [] }; - } - tool_resources[EToolResources.execute_code].files.push(file); - } else if (file.embedded === true) { - const file_search = tool_resources[EToolResources.file_search] ?? {}; - if (!file_search.files) { - tool_resources[EToolResources.file_search] = { ...file_search, files: [] }; - } - tool_resources[EToolResources.file_search].files.push(file); - } else if ( - requestFileSet.has(file.file_id) && - file.type.startsWith('image') && - file.height && - file.width - ) { - const image_edit = tool_resources[EToolResources.image_edit] ?? {}; - if (!image_edit.files) { - tool_resources[EToolResources.image_edit] = { ...image_edit, files: [] }; - } - tool_resources[EToolResources.image_edit].files.push(file); - } - - attachments.push(file); - } - return { attachments, tool_resources }; - } catch (error) { - logger.error('Error priming resources', error); - return { attachments: _attachments, tool_resources: _tool_resources }; - } -}; - -/** - * @param {...string | number} values - * @returns {string | number | undefined} - */ -function optionalChainWithEmptyCheck(...values) { - for (const value of values) { - if (value !== undefined && value !== null && value !== '') { - return value; - } - } - return values[values.length - 1]; -} - -/** - * @param {object} params - * @param {ServerRequest} params.req - * @param {ServerResponse} params.res - * @param {Agent} params.agent - * @param {Set} [params.allowedProviders] - * @param {object} [params.endpointOption] - * @param {boolean} [params.isInitialAgent] - * @returns {Promise} - */ -const initializeAgentOptions = async ({ - req, - res, - agent, - endpointOption, - allowedProviders, - isInitialAgent = false, -}) => { - if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) { - throw new Error( - `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`, - ); - } - let currentFiles; - /** @type {Array} */ - const requestFiles = req.body.files ?? []; - if ( - isInitialAgent && - req.body.conversationId != null && - (agent.model_parameters?.resendFiles ?? true) === true - ) { - const fileIds = (await getConvoFiles(req.body.conversationId)) ?? []; - /** @type {Set} */ - const toolResourceSet = new Set(); - for (const tool of agent.tools) { - if (EToolResources[tool]) { - toolResourceSet.add(EToolResources[tool]); - } - } - const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet); - if (requestFiles.length || toolFiles.length) { - currentFiles = await processFiles(requestFiles.concat(toolFiles)); - } - } else if (isInitialAgent && requestFiles.length) { - currentFiles = await processFiles(requestFiles); - } - - const { attachments, tool_resources } = await primeResources({ - req, - attachments: currentFiles, - tool_resources: agent.tool_resources, - requestFileSet: new Set(requestFiles.map((file) => file.file_id)), - }); - - const provider = agent.provider; - const { tools, toolContextMap } = await loadAgentTools({ - req, - res, - agent: { - id: agent.id, - tools: agent.tools, - provider, - model: agent.model, - }, - tool_resources, - }); - - agent.endpoint = provider; - let getOptions = providerConfigMap[provider]; - if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { - agent.provider = provider.toLowerCase(); - getOptions = providerConfigMap[agent.provider]; - } else if (!getOptions) { - const customEndpointConfig = await getCustomEndpointConfig(provider); - if (!customEndpointConfig) { - throw new Error(`Provider ${provider} not supported`); - } - getOptions = initCustom; - agent.provider = Providers.OPENAI; - } - const model_parameters = Object.assign( - {}, - agent.model_parameters ?? { model: agent.model }, - isInitialAgent === true ? endpointOption?.model_parameters : {}, - ); - const _endpointOption = - isInitialAgent === true - ? Object.assign({}, endpointOption, { model_parameters }) - : { model_parameters }; - - const options = await getOptions({ - req, - res, - optionsOnly: true, - overrideEndpoint: provider, - overrideModel: agent.model, - endpointOption: _endpointOption, - }); - - if ( - agent.endpoint === EModelEndpoint.azureOpenAI && - options.llmConfig?.azureOpenAIApiInstanceName == null - ) { - agent.provider = Providers.OPENAI; - } - - if (options.provider != null) { - agent.provider = options.provider; - } - - /** @type {import('@librechat/agents').ClientOptions} */ - agent.model_parameters = Object.assign(model_parameters, options.llmConfig); - if (options.configOptions) { - agent.model_parameters.configuration = options.configOptions; - } - - if (!agent.model_parameters.model) { - agent.model_parameters.model = agent.model; - } - - if (agent.instructions && agent.instructions !== '') { - agent.instructions = replaceSpecialVars({ - text: agent.instructions, - user: req.user, - }); - } - - if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { - agent.additional_instructions = generateArtifactsPrompt({ - endpoint: agent.provider, - artifacts: agent.artifacts, - }); - } - - const tokensModel = - agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model; - const maxTokens = optionalChainWithEmptyCheck( - agent.model_parameters.maxOutputTokens, - agent.model_parameters.maxTokens, - 0, - ); - const maxContextTokens = optionalChainWithEmptyCheck( - agent.model_parameters.maxContextTokens, - agent.max_context_tokens, - getModelMaxTokens(tokensModel, providerEndpointMap[provider]), - 4096, - ); - return { - ...agent, - tools, - attachments, - toolContextMap, - maxContextTokens: (maxContextTokens - maxTokens) * 0.9, }; -}; +} const initializeClient = async ({ req, res, endpointOption }) => { if (!endpointOption) { @@ -313,7 +60,6 @@ const initializeClient = async ({ req, res, endpointOption }) => { throw new Error('No agent promise provided'); } - // Initialize primary agent const primaryAgent = await endpointOption.agent; if (!primaryAgent) { throw new Error('Agent not found'); @@ -323,10 +69,18 @@ const initializeClient = async ({ req, res, endpointOption }) => { /** @type {Set} */ const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders); - // Handle primary agent - const primaryConfig = await initializeAgentOptions({ + const loadTools = createToolLoader(); + /** @type {Array} */ + const requestFiles = req.body.files ?? []; + /** @type {string} */ + const conversationId = req.body.conversationId; + + const primaryConfig = await initializeAgent({ req, res, + loadTools, + requestFiles, + conversationId, agent: primaryAgent, endpointOption, allowedProviders, @@ -340,10 +94,13 @@ const initializeClient = async ({ req, res, endpointOption }) => { if (!agent) { throw new Error(`Agent ${agentId} not found`); } - const config = await initializeAgentOptions({ + const config = await initializeAgent({ req, res, agent, + loadTools, + requestFiles, + conversationId, endpointOption, allowedProviders, }); diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index fc8024af0..88acef23e 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -1,5 +1,6 @@ const OpenAI = require('openai'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { constructAzureURL, isUserProvided } = require('@librechat/api'); const { ErrorTypes, EModelEndpoint, @@ -12,8 +13,6 @@ const { checkUserKeyExpiry, } = require('~/server/services/UserService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); -const { isUserProvided } = require('~/server/utils'); -const { constructAzureURL } = require('~/utils'); class Files { constructor(client) { diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index da332060e..fc5536abb 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,4 +1,5 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); +const { createHandleLLMNewToken } = require('@librechat/api'); const { AuthType, Constants, @@ -8,7 +9,6 @@ const { removeNullishValues, } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); const getOptions = async ({ req, overrideModel, endpointOption }) => { const { diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 39def8d0d..754abef5a 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -6,10 +6,9 @@ const { extractEnvVariable, } = require('librechat-data-provider'); const { Providers } = require('@librechat/agents'); +const { getOpenAIConfig, createHandleLLMNewToken } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { getLLMConfig } = require('~/server/services/Endpoints/openAI/llm'); const { getCustomEndpointConfig } = require('~/server/services/Config'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); const { fetchModels } = require('~/server/services/ModelService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); const { isUserProvided } = require('~/server/utils'); @@ -144,7 +143,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid clientOptions, ); clientOptions.modelOptions.user = req.user.id; - const options = getLLMConfig(apiKey, clientOptions, endpoint); + const options = getOpenAIConfig(apiKey, clientOptions, endpoint); if (!customOptions.streamRate) { return options; } diff --git a/api/server/services/Endpoints/gptPlugins/initialize.js b/api/server/services/Endpoints/gptPlugins/initialize.js index 7bfb43f00..d2af6c757 100644 --- a/api/server/services/Endpoints/gptPlugins/initialize.js +++ b/api/server/services/Endpoints/gptPlugins/initialize.js @@ -1,11 +1,10 @@ const { EModelEndpoint, - mapModelToAzureConfig, resolveHeaders, + mapModelToAzureConfig, } = require('librechat-data-provider'); +const { isEnabled, isUserProvided, getAzureCredentials } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { isEnabled, isUserProvided } = require('~/server/utils'); -const { getAzureCredentials } = require('~/utils'); const { PluginsClient } = require('~/app'); const initializeClient = async ({ req, res, endpointOption }) => { diff --git a/api/server/services/Endpoints/gptPlugins/initialize.spec.js b/api/server/services/Endpoints/gptPlugins/initialize.spec.js index 02199c939..f9cb2750a 100644 --- a/api/server/services/Endpoints/gptPlugins/initialize.spec.js +++ b/api/server/services/Endpoints/gptPlugins/initialize.spec.js @@ -114,11 +114,11 @@ describe('gptPlugins/initializeClient', () => { test('should initialize PluginsClient with Azure credentials when PLUGINS_USE_AZURE is true', async () => { process.env.AZURE_API_KEY = 'test-azure-api-key'; (process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), - (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), - (process.env.PLUGINS_USE_AZURE = 'true'); + (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), + (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), + (process.env.PLUGINS_USE_AZURE = 'true'); process.env.DEBUG_PLUGINS = 'false'; process.env.OPENAI_SUMMARIZE = 'false'; diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index 714ed5a1e..bc0907b3d 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -4,12 +4,15 @@ const { resolveHeaders, mapModelToAzureConfig, } = require('librechat-data-provider'); +const { + isEnabled, + isUserProvided, + getOpenAIConfig, + getAzureCredentials, + createHandleLLMNewToken, +} = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { getLLMConfig } = require('~/server/services/Endpoints/openAI/llm'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); -const { isEnabled, isUserProvided } = require('~/server/utils'); const OpenAIClient = require('~/app/clients/OpenAIClient'); -const { getAzureCredentials } = require('~/utils'); const initializeClient = async ({ req, @@ -140,7 +143,7 @@ const initializeClient = async ({ modelOptions.model = modelName; clientOptions = Object.assign({ modelOptions }, clientOptions); clientOptions.modelOptions.user = req.user.id; - const options = getLLMConfig(apiKey, clientOptions); + const options = getOpenAIConfig(apiKey, clientOptions); const streamRate = clientOptions.streamRate; if (!streamRate) { return options; diff --git a/api/server/services/Endpoints/openAI/llm.js b/api/server/services/Endpoints/openAI/llm.js deleted file mode 100644 index c1fd090b2..000000000 --- a/api/server/services/Endpoints/openAI/llm.js +++ /dev/null @@ -1,170 +0,0 @@ -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { KnownEndpoints } = require('librechat-data-provider'); -const { sanitizeModelName, constructAzureURL } = require('~/utils'); -const { isEnabled } = require('~/server/utils'); - -/** - * Generates configuration options for creating a language model (LLM) instance. - * @param {string} apiKey - The API key for authentication. - * @param {Object} options - Additional options for configuring the LLM. - * @param {Object} [options.modelOptions] - Model-specific options. - * @param {string} [options.modelOptions.model] - The name of the model to use. - * @param {string} [options.modelOptions.user] - The user ID - * @param {number} [options.modelOptions.temperature] - Controls randomness in output generation (0-2). - * @param {number} [options.modelOptions.top_p] - Controls diversity via nucleus sampling (0-1). - * @param {number} [options.modelOptions.frequency_penalty] - Reduces repetition of token sequences (-2 to 2). - * @param {number} [options.modelOptions.presence_penalty] - Encourages discussing new topics (-2 to 2). - * @param {number} [options.modelOptions.max_tokens] - The maximum number of tokens to generate. - * @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens. - * @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used. - * @param {boolean} [options.useOpenRouter] - Flag to use OpenRouter API. - * @param {Object} [options.headers] - Additional headers for API requests. - * @param {string} [options.proxy] - Proxy server URL. - * @param {Object} [options.azure] - Azure-specific configurations. - * @param {boolean} [options.streaming] - Whether to use streaming mode. - * @param {Object} [options.addParams] - Additional parameters to add to the model options. - * @param {string[]} [options.dropParams] - Parameters to remove from the model options. - * @param {string|null} [endpoint=null] - The endpoint name - * @returns {Object} Configuration options for creating an LLM instance. - */ -function getLLMConfig(apiKey, options = {}, endpoint = null) { - let { - modelOptions = {}, - reverseProxyUrl, - defaultQuery, - headers, - proxy, - azure, - streaming = true, - addParams, - dropParams, - } = options; - - /** @type {OpenAIClientOptions} */ - let llmConfig = { - streaming, - }; - - Object.assign(llmConfig, modelOptions); - - if (addParams && typeof addParams === 'object') { - Object.assign(llmConfig, addParams); - } - /** Note: OpenAI Web Search models do not support any known parameters besdies `max_tokens` */ - if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) { - const searchExcludeParams = [ - 'frequency_penalty', - 'presence_penalty', - 'temperature', - 'top_p', - 'top_k', - 'stop', - 'logit_bias', - 'seed', - 'response_format', - 'n', - 'logprobs', - 'user', - ]; - - dropParams = dropParams || []; - dropParams = [...new Set([...dropParams, ...searchExcludeParams])]; - } - - if (dropParams && Array.isArray(dropParams)) { - dropParams.forEach((param) => { - if (llmConfig[param]) { - llmConfig[param] = undefined; - } - }); - } - - let useOpenRouter; - /** @type {OpenAIClientOptions['configuration']} */ - const configOptions = {}; - if ( - (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter)) || - (endpoint && endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) - ) { - useOpenRouter = true; - llmConfig.include_reasoning = true; - configOptions.baseURL = reverseProxyUrl; - configOptions.defaultHeaders = Object.assign( - { - 'HTTP-Referer': 'https://librechat.ai', - 'X-Title': 'LibreChat', - }, - headers, - ); - } else if (reverseProxyUrl) { - configOptions.baseURL = reverseProxyUrl; - if (headers) { - configOptions.defaultHeaders = headers; - } - } - - if (defaultQuery) { - configOptions.defaultQuery = defaultQuery; - } - - if (proxy) { - const proxyAgent = new HttpsProxyAgent(proxy); - Object.assign(configOptions, { - httpAgent: proxyAgent, - httpsAgent: proxyAgent, - }); - } - - if (azure) { - const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME); - azure.azureOpenAIApiDeploymentName = useModelName - ? sanitizeModelName(llmConfig.model) - : azure.azureOpenAIApiDeploymentName; - - if (process.env.AZURE_OPENAI_DEFAULT_MODEL) { - llmConfig.model = process.env.AZURE_OPENAI_DEFAULT_MODEL; - } - - if (configOptions.baseURL) { - const azureURL = constructAzureURL({ - baseURL: configOptions.baseURL, - azureOptions: azure, - }); - azure.azureOpenAIBasePath = azureURL.split(`/${azure.azureOpenAIApiDeploymentName}`)[0]; - } - - Object.assign(llmConfig, azure); - llmConfig.model = llmConfig.azureOpenAIApiDeploymentName; - } else { - llmConfig.apiKey = apiKey; - // Object.assign(llmConfig, { - // configuration: { apiKey }, - // }); - } - - if (process.env.OPENAI_ORGANIZATION && this.azure) { - llmConfig.organization = process.env.OPENAI_ORGANIZATION; - } - - if (useOpenRouter && llmConfig.reasoning_effort != null) { - llmConfig.reasoning = { - effort: llmConfig.reasoning_effort, - }; - delete llmConfig.reasoning_effort; - } - - if (llmConfig?.['max_tokens'] != null) { - /** @type {number} */ - llmConfig.maxTokens = llmConfig['max_tokens']; - delete llmConfig['max_tokens']; - } - - return { - /** @type {OpenAIClientOptions} */ - llmConfig, - /** @type {OpenAIClientOptions['configuration']} */ - configOptions, - }; -} - -module.exports = { getLLMConfig }; diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index d6c8cc414..49a800336 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -2,9 +2,9 @@ const axios = require('axios'); const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); +const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { getCustomConfig } = require('~/server/services/Config'); -const { genAzureEndpoint } = require('~/utils'); const { logger } = require('~/config'); /** diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index cd718fdfc..34d820215 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -1,8 +1,8 @@ const axios = require('axios'); +const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getCustomConfig } = require('~/server/services/Config'); -const { genAzureEndpoint } = require('~/utils'); const { logger } = require('~/config'); /** diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index b9baef462..dc3d3f003 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,6 +1,6 @@ const { z } = require('zod'); const { tool } = require('@langchain/core/tools'); -const { normalizeServerName } = require('librechat-mcp'); +const { normalizeServerName } = require('@librechat/api'); const { Constants: AgentConstants, Providers } = require('@librechat/agents'); const { Constants, diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js index 7578c036b..c98fdb60b 100644 --- a/api/server/services/start/interface.js +++ b/api/server/services/start/interface.js @@ -2,6 +2,7 @@ const { SystemRoles, Permissions, PermissionTypes, + isMemoryEnabled, removeNullishValues, } = require('librechat-data-provider'); const { updateAccessPermissions } = require('~/models/Role'); @@ -20,6 +21,14 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol const hasModelSpecs = config?.modelSpecs?.list?.length > 0; const includesAddedEndpoints = config?.modelSpecs?.addedEndpoints?.length > 0; + const memoryConfig = config?.memory; + const memoryEnabled = isMemoryEnabled(memoryConfig); + /** Only disable memories if memory config is present but disabled/invalid */ + const shouldDisableMemories = memoryConfig && !memoryEnabled; + /** Check if personalization is enabled (defaults to true if memory is configured and enabled) */ + const isPersonalizationEnabled = + memoryConfig && memoryEnabled && memoryConfig.personalize !== false; + /** @type {TCustomConfig['interface']} */ const loadedInterface = removeNullishValues({ endpointsMenu: @@ -33,6 +42,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks, + memories: shouldDisableMemories ? false : (interfaceConfig?.memories ?? defaults.memories), prompts: interfaceConfig?.prompts ?? defaults.prompts, multiConvo: interfaceConfig?.multiConvo ?? defaults.multiConvo, agents: interfaceConfig?.agents ?? defaults.agents, @@ -45,6 +55,10 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol await updateAccessPermissions(roleName, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: loadedInterface.memories, + [Permissions.OPT_OUT]: isPersonalizationEnabled, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo }, [PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, @@ -54,6 +68,10 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol await updateAccessPermissions(SystemRoles.ADMIN, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: loadedInterface.memories, + [Permissions.OPT_OUT]: isPersonalizationEnabled, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo }, [PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, diff --git a/api/server/services/start/interface.spec.js b/api/server/services/start/interface.spec.js index d0dcfaf55..1a05c9cf1 100644 --- a/api/server/services/start/interface.spec.js +++ b/api/server/services/start/interface.spec.js @@ -12,6 +12,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: true, multiConvo: true, agents: true, temporaryChat: true, @@ -26,6 +27,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -39,6 +41,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: false, bookmarks: false, + memories: false, multiConvo: false, agents: false, temporaryChat: false, @@ -53,6 +56,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: false }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, @@ -70,6 +74,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -83,6 +88,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: undefined, bookmarks: undefined, + memories: undefined, multiConvo: undefined, agents: undefined, temporaryChat: undefined, @@ -97,6 +103,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -110,6 +117,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: undefined, agents: true, temporaryChat: undefined, @@ -124,6 +132,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -138,6 +147,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: true, multiConvo: true, agents: true, temporaryChat: true, @@ -151,6 +161,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -168,6 +179,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -185,6 +197,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -202,6 +215,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -215,6 +229,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: true, agents: false, temporaryChat: true, @@ -228,6 +243,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -242,6 +258,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: false, multiConvo: false, agents: undefined, temporaryChat: undefined, @@ -255,6 +272,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: false }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -268,6 +286,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: true, agents: false, temporaryChat: true, @@ -281,6 +300,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 86c17f1dd..680da5da4 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -1,5 +1,3 @@ -const path = require('path'); -const crypto = require('crypto'); const { Capabilities, EModelEndpoint, @@ -218,38 +216,6 @@ function normalizeEndpointName(name = '') { return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; } -/** - * Sanitize a filename by removing any directory components, replacing non-alphanumeric characters - * @param {string} inputName - * @returns {string} - */ -function sanitizeFilename(inputName) { - // Remove any directory components - let name = path.basename(inputName); - - // Replace any non-alphanumeric characters except for '.' and '-' - name = name.replace(/[^a-zA-Z0-9.-]/g, '_'); - - // Ensure the name doesn't start with a dot (hidden file in Unix-like systems) - if (name.startsWith('.') || name === '') { - name = '_' + name; - } - - // Limit the length of the filename - const MAX_LENGTH = 255; - if (name.length > MAX_LENGTH) { - const ext = path.extname(name); - const nameWithoutExt = path.basename(name, ext); - name = - nameWithoutExt.slice(0, MAX_LENGTH - ext.length - 7) + - '-' + - crypto.randomBytes(3).toString('hex') + - ext; - } - - return name; -} - module.exports = { isEnabled, handleText, @@ -260,6 +226,5 @@ module.exports = { generateConfig, addSpaceIfNeeded, createOnProgress, - sanitizeFilename, normalizeEndpointName, }; diff --git a/api/server/utils/handleText.spec.js b/api/server/utils/handleText.spec.js deleted file mode 100644 index 2cd6c51f9..000000000 --- a/api/server/utils/handleText.spec.js +++ /dev/null @@ -1,103 +0,0 @@ -const { isEnabled, sanitizeFilename } = require('./handleText'); - -describe('isEnabled', () => { - test('should return true when input is "true"', () => { - expect(isEnabled('true')).toBe(true); - }); - - test('should return true when input is "TRUE"', () => { - expect(isEnabled('TRUE')).toBe(true); - }); - - test('should return true when input is true', () => { - expect(isEnabled(true)).toBe(true); - }); - - test('should return false when input is "false"', () => { - expect(isEnabled('false')).toBe(false); - }); - - test('should return false when input is false', () => { - expect(isEnabled(false)).toBe(false); - }); - - test('should return false when input is null', () => { - expect(isEnabled(null)).toBe(false); - }); - - test('should return false when input is undefined', () => { - expect(isEnabled()).toBe(false); - }); - - test('should return false when input is an empty string', () => { - expect(isEnabled('')).toBe(false); - }); - - test('should return false when input is a whitespace string', () => { - expect(isEnabled(' ')).toBe(false); - }); - - test('should return false when input is a number', () => { - expect(isEnabled(123)).toBe(false); - }); - - test('should return false when input is an object', () => { - expect(isEnabled({})).toBe(false); - }); - - test('should return false when input is an array', () => { - expect(isEnabled([])).toBe(false); - }); -}); - -jest.mock('crypto', () => { - const actualModule = jest.requireActual('crypto'); - return { - ...actualModule, - randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')), - }; -}); - -describe('sanitizeFilename', () => { - test('removes directory components (1/2)', () => { - expect(sanitizeFilename('/path/to/file.txt')).toBe('file.txt'); - }); - - test('removes directory components (2/2)', () => { - expect(sanitizeFilename('../../../../file.txt')).toBe('file.txt'); - }); - - test('replaces non-alphanumeric characters', () => { - expect(sanitizeFilename('file name@#$.txt')).toBe('file_name___.txt'); - }); - - test('preserves dots and hyphens', () => { - expect(sanitizeFilename('file-name.with.dots.txt')).toBe('file-name.with.dots.txt'); - }); - - test('prepends underscore to filenames starting with a dot', () => { - expect(sanitizeFilename('.hiddenfile')).toBe('_.hiddenfile'); - }); - - test('truncates long filenames', () => { - const longName = 'a'.repeat(300) + '.txt'; - const result = sanitizeFilename(longName); - expect(result.length).toBe(255); - expect(result).toMatch(/^a+-abc123\.txt$/); - }); - - test('handles filenames with no extension', () => { - const longName = 'a'.repeat(300); - const result = sanitizeFilename(longName); - expect(result.length).toBe(255); - expect(result).toMatch(/^a+-abc123$/); - }); - - test('handles empty input', () => { - expect(sanitizeFilename('')).toBe('_'); - }); - - test('handles input with only special characters', () => { - expect(sanitizeFilename('@#$%^&*')).toBe('_______'); - }); -}); diff --git a/api/typedefs.js b/api/typedefs.js index 8da5b3480..5bc7ebf66 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1073,7 +1073,7 @@ /** * @exports MCPServers - * @typedef {import('librechat-mcp').MCPServers} MCPServers + * @typedef {import('@librechat/api').MCPServers} MCPServers * @memberof typedefs */ @@ -1085,31 +1085,31 @@ /** * @exports MCPManager - * @typedef {import('librechat-mcp').MCPManager} MCPManager + * @typedef {import('@librechat/api').MCPManager} MCPManager * @memberof typedefs */ /** * @exports FlowStateManager - * @typedef {import('librechat-mcp').FlowStateManager} FlowStateManager + * @typedef {import('@librechat/api').FlowStateManager} FlowStateManager * @memberof typedefs */ /** * @exports LCAvailableTools - * @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools + * @typedef {import('@librechat/api').LCAvailableTools} LCAvailableTools * @memberof typedefs */ /** * @exports LCTool - * @typedef {import('librechat-mcp').LCTool} LCTool + * @typedef {import('@librechat/api').LCTool} LCTool * @memberof typedefs */ /** * @exports FormattedContent - * @typedef {import('librechat-mcp').FormattedContent} FormattedContent + * @typedef {import('@librechat/api').FormattedContent} FormattedContent * @memberof typedefs */ @@ -1232,7 +1232,7 @@ * @typedef {Object} AgentClientOptions * @property {Agent} agent - The agent configuration object * @property {string} endpoint - The endpoint identifier for the agent - * @property {Object} req - The request object + * @property {ServerRequest} req - The request object * @property {string} [name] - The username * @property {string} [modelLabel] - The label for the model being used * @property {number} [maxContextTokens] - Maximum number of tokens allowed in context diff --git a/api/utils/index.js b/api/utils/index.js index 62d61586b..6a304efd6 100644 --- a/api/utils/index.js +++ b/api/utils/index.js @@ -1,7 +1,6 @@ const loadYaml = require('./loadYaml'); const axiosHelpers = require('./axios'); const tokenHelpers = require('./tokens'); -const azureUtils = require('./azureUtils'); const deriveBaseURL = require('./deriveBaseURL'); const extractBaseURL = require('./extractBaseURL'); const findMessageContent = require('./findMessageContent'); @@ -10,7 +9,6 @@ module.exports = { loadYaml, deriveBaseURL, extractBaseURL, - ...azureUtils, ...axiosHelpers, ...tokenHelpers, findMessageContent, diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 8c1ff2ebb..0a1b4616a 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -9,6 +9,7 @@ import type { } from 'librechat-data-provider'; import { ThinkingButton } from '~/components/Artifacts/Thinking'; import { MessageContext, SearchContext } from '~/Providers'; +import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import useLocalize from '~/hooks/useLocalize'; import { mapAttachments } from '~/utils/map'; @@ -72,6 +73,7 @@ const ContentParts = memo( return hasThinkPart && allThinkPartsHaveContent; }, [content]); + if (!content) { return null; } @@ -103,6 +105,7 @@ const ContentParts = memo( return ( <> + {hasReasoningParts && (
diff --git a/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx new file mode 100644 index 000000000..7af4e9fcd --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx @@ -0,0 +1,143 @@ +import { Tools } from 'librechat-data-provider'; +import { useState, useRef, useMemo, useLayoutEffect, useEffect } from 'react'; +import type { MemoryArtifact, TAttachment } from 'librechat-data-provider'; +import MemoryInfo from './MemoryInfo'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) { + const localize = useLocalize(); + const [showInfo, setShowInfo] = useState(false); + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const prevShowInfoRef = useRef(showInfo); + + const memoryArtifacts = useMemo(() => { + const result: MemoryArtifact[] = []; + for (const attachment of attachments ?? []) { + if (attachment?.[Tools.memory] != null) { + result.push(attachment[Tools.memory]); + } + } + return result; + }, [attachments]); + + useLayoutEffect(() => { + if (showInfo !== prevShowInfoRef.current) { + prevShowInfoRef.current = showInfo; + setIsAnimating(true); + + if (showInfo && contentRef.current) { + requestAnimationFrame(() => { + if (contentRef.current) { + const height = contentRef.current.scrollHeight; + setContentHeight(height + 4); + } + }); + } else { + setContentHeight(0); + } + + const timer = setTimeout(() => { + setIsAnimating(false); + }, 400); + + return () => clearTimeout(timer); + } + }, [showInfo]); + + useEffect(() => { + if (!contentRef.current) { + return; + } + const resizeObserver = new ResizeObserver((entries) => { + if (showInfo && !isAnimating) { + for (const entry of entries) { + if (entry.target === contentRef.current) { + setContentHeight(entry.contentRect.height + 4); + } + } + } + }); + resizeObserver.observe(contentRef.current); + return () => { + resizeObserver.disconnect(); + }; + }, [showInfo, isAnimating]); + + if (!memoryArtifacts || memoryArtifacts.length === 0) { + return null; + } + + return ( + <> +
+
+ +
+
+
+
+
+ {showInfo && } +
+
+
+ + ); +} diff --git a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx new file mode 100644 index 000000000..574c2e8f5 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx @@ -0,0 +1,61 @@ +import type { MemoryArtifact } from 'librechat-data-provider'; +import { useLocalize } from '~/hooks'; + +export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) { + const localize = useLocalize(); + if (memoryArtifacts.length === 0) { + return null; + } + + // Group artifacts by type + const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update'); + const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete'); + + if (updatedMemories.length === 0 && deletedMemories.length === 0) { + return null; + } + + return ( +
+ {updatedMemories.length > 0 && ( +
+

+ {localize('com_ui_memory_updated_items')} +

+
+ {updatedMemories.map((artifact, index) => ( +
+
+ {artifact.key} +
+
+ {artifact.value} +
+
+ ))} +
+
+ )} + + {deletedMemories.length > 0 && ( +
+

+ {localize('com_ui_memory_deleted_items')} +

+
+ {deletedMemories.map((artifact, index) => ( +
+
+ {artifact.key} +
+
+ {localize('com_ui_memory_deleted')} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 7bc1a8e75..23d7a456b 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -5,9 +5,27 @@ import { SettingsTabValues } from 'librechat-data-provider'; import { useGetStartupConfig } from '~/data-provider'; import type { TDialogProps } from '~/common'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg'; -import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs'; +import { + GearIcon, + DataIcon, + SpeechIcon, + UserIcon, + ExperimentIcon, + PersonalizationIcon, +} from '~/components/svg'; +import { + General, + Chat, + Speech, + Beta, + Commands, + Data, + Account, + Balance, + Personalization, +} from './SettingsTabs'; import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks'; +import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; import { cn } from '~/utils'; export default function Settings({ open, onOpenChange }: TDialogProps) { @@ -16,6 +34,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { const localize = useLocalize(); const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL); const tabRefs = useRef({}); + const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); const handleKeyDown = (event: React.KeyboardEvent) => { const tabs: SettingsTabValues[] = [ @@ -24,6 +43,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { SettingsTabValues.BETA, SettingsTabValues.COMMANDS, SettingsTabValues.SPEECH, + ...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []), SettingsTabValues.DATA, ...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []), SettingsTabValues.ACCOUNT, @@ -80,6 +100,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { icon: , label: 'com_nav_setting_speech', }, + ...(hasAnyPersonalizationFeature + ? [ + { + value: SettingsTabValues.PERSONALIZATION, + icon: , + label: 'com_nav_setting_personalization' as TranslationKeys, + }, + ] + : []), { value: SettingsTabValues.DATA, icon: , @@ -87,11 +116,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { }, ...(startupConfig?.balance?.enabled ? [ - { - value: SettingsTabValues.BALANCE, + { + value: SettingsTabValues.BALANCE, icon: , - label: 'com_nav_setting_balance' as TranslationKeys, - }, + label: 'com_nav_setting_balance' as TranslationKeys, + }, ] : ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])), { @@ -213,6 +242,14 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { + {hasAnyPersonalizationFeature && ( + + + + )} diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx new file mode 100644 index 000000000..929646a44 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react'; +import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider'; +import { useToastContext } from '~/Providers'; +import { Switch } from '~/components/ui'; +import { useLocalize } from '~/hooks'; + +interface PersonalizationProps { + hasMemoryOptOut: boolean; + hasAnyPersonalizationFeature: boolean; +} + +export default function Personalization({ + hasMemoryOptOut, + hasAnyPersonalizationFeature, +}: PersonalizationProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data: user } = useGetUserQuery(); + const [referenceSavedMemories, setReferenceSavedMemories] = useState(true); + + const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({ + onSuccess: () => { + showToast({ + message: localize('com_ui_preferences_updated'), + status: 'success', + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_error_updating_preferences'), + status: 'error', + }); + // Revert the toggle on error + setReferenceSavedMemories((prev) => !prev); + }, + }); + + // Initialize state from user data + useEffect(() => { + if (user?.personalization?.memories !== undefined) { + setReferenceSavedMemories(user.personalization.memories); + } + }, [user?.personalization?.memories]); + + const handleMemoryToggle = (checked: boolean) => { + setReferenceSavedMemories(checked); + updateMemoryPreferencesMutation.mutate({ memories: checked }); + }; + + if (!hasAnyPersonalizationFeature) { + return ( +
+
{localize('com_ui_no_personalization_available')}
+
+ ); + } + + return ( +
+ {/* Memory Settings Section */} + {hasMemoryOptOut && ( + <> +
+
{localize('com_ui_memory')}
+
+ +
+
+
+ {localize('com_ui_reference_saved_memories')} +
+
+ {localize('com_ui_reference_saved_memories_description')} +
+
+ +
+ + )} +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 380d9a7a6..b3398431f 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -7,3 +7,4 @@ export { RevokeKeysButton } from './Data/RevokeKeysButton'; export { default as Account } from './Account/Account'; export { default as Balance } from './Balance/Balance'; export { default as Speech } from './Speech/Speech'; +export { default as Personalization } from './Personalization'; diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 0d0ab9a2f..19d2cde1f 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -44,11 +44,11 @@ export default function FilterPrompts({ const categoryOptions = categories ? [...categories] : [ - { - value: SystemCategories.NO_CATEGORY, - label: localize('com_ui_no_category'), - }, - ]; + { + value: SystemCategories.NO_CATEGORY, + label: localize('com_ui_no_category'), + }, + ]; return [...baseOptions, ...categoryOptions]; }, [categories, localize]); diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index 5cfd77ec2..e359d13ea 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation'; +import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { useMediaQuery, usePromptGroupsNav } from '~/hooks'; import List from '~/components/Prompts/Groups/List'; import { cn } from '~/utils'; @@ -38,14 +39,17 @@ export default function GroupSidePanel({
- +
+ {isChatRoute && } + +
); } diff --git a/client/src/components/Prompts/Groups/PanelNavigation.tsx b/client/src/components/Prompts/Groups/PanelNavigation.tsx index 76395e842..5600415d7 100644 --- a/client/src/components/Prompts/Groups/PanelNavigation.tsx +++ b/client/src/components/Prompts/Groups/PanelNavigation.tsx @@ -19,11 +19,13 @@ function PanelNavigation({ }) { const localize = useLocalize(); return ( -
-
- {!isChatRoute && } -
-
+ <> +
{!isChatRoute && }
+
@@ -36,7 +38,7 @@ function PanelNavigation({ {localize('com_ui_next')}
-
+ ); } diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx index 444646c5e..6bc84005c 100644 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ b/client/src/components/Prompts/PromptsAccordion.tsx @@ -1,19 +1,17 @@ import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel'; import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; -import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { usePromptGroupsNav } from '~/hooks'; export default function PromptsAccordion() { const groupsNav = usePromptGroupsNav(); return (
- -
- + + +
-
); diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx index 12de8b452..0506b3b0d 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx @@ -80,13 +80,13 @@ const BookmarkTable = () => { -
{localize('com_ui_bookmarks_title')}
+
{localize('com_ui_bookmarks_title')}
-
{localize('com_ui_bookmarks_count')}
+
{localize('com_ui_bookmarks_count')}
-
{localize('com_assistants_actions')}
+
{localize('com_assistants_actions')}
diff --git a/client/src/components/SidePanel/Memories/AdminSettings.tsx b/client/src/components/SidePanel/Memories/AdminSettings.tsx new file mode 100644 index 000000000..fcb347228 --- /dev/null +++ b/client/src/components/SidePanel/Memories/AdminSettings.tsx @@ -0,0 +1,212 @@ +import * as Ariakit from '@ariakit/react'; +import { useMemo, useEffect, useState } from 'react'; +import { ShieldEllipsis } from 'lucide-react'; +import { useForm, Controller } from 'react-hook-form'; +import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider'; +import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form'; +import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui'; +import { useUpdateMemoryPermissionsMutation } from '~/data-provider'; +import { Button, Switch, DropdownPopup } from '~/components/ui'; +import { useLocalize, useAuthContext } from '~/hooks'; +import { useToastContext } from '~/Providers'; + +type FormValues = Record; + +type LabelControllerProps = { + label: string; + memoryPerm: Permissions; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; +}; + +const LabelController: React.FC = ({ control, memoryPerm, label }) => ( +
+ {label} + ( + + )} + /> +
+); + +const AdminSettings = () => { + const localize = useLocalize(); + const { user, roles } = useAuthContext(); + const { showToast } = useToastContext(); + const { mutate, isLoading } = useUpdateMemoryPermissionsMutation({ + onSuccess: () => { + showToast({ status: 'success', message: localize('com_ui_saved') }); + }, + onError: () => { + showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }); + }, + }); + + const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(SystemRoles.USER); + + const defaultValues = useMemo(() => { + if (roles?.[selectedRole]?.permissions) { + return roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]; + } + return roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]; + }, [roles, selectedRole]); + + const { + reset, + control, + setValue, + getValues, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues, + }); + + useEffect(() => { + if (roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]) { + reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]); + } else { + reset(roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]); + } + }, [roles, selectedRole, reset]); + + if (user?.role !== SystemRoles.ADMIN) { + return null; + } + + const labelControllerData = [ + { + memoryPerm: Permissions.USE, + label: localize('com_ui_memories_allow_use'), + }, + { + memoryPerm: Permissions.CREATE, + label: localize('com_ui_memories_allow_create'), + }, + { + memoryPerm: Permissions.UPDATE, + label: localize('com_ui_memories_allow_update'), + }, + { + memoryPerm: Permissions.READ, + label: localize('com_ui_memories_allow_read'), + }, + { + memoryPerm: Permissions.OPT_OUT, + label: localize('com_ui_memories_allow_opt_out'), + }, + ]; + + const onSubmit = (data: FormValues) => { + mutate({ roleName: selectedRole, updates: data }); + }; + + const roleDropdownItems = [ + { + label: SystemRoles.USER, + onClick: () => { + setSelectedRole(SystemRoles.USER); + }, + }, + { + label: SystemRoles.ADMIN, + onClick: () => { + setSelectedRole(SystemRoles.ADMIN); + }, + }, + ]; + + return ( + + + + + + {`${localize('com_ui_admin_settings')} - ${localize( + 'com_ui_memories', + )}`} +
+ {/* Role selection dropdown */} +
+ {localize('com_ui_role_select')}: + + {selectedRole} + + } + items={roleDropdownItems} + itemClassName="items-center justify-center" + sameWidth={true} + /> +
+ {/* Permissions form */} +
+
+ {labelControllerData.map(({ memoryPerm, label }) => ( +
+ + {selectedRole === SystemRoles.ADMIN && memoryPerm === Permissions.USE && ( + <> +
+ {localize('com_ui_admin_access_warning')} + {'\n'} + + {localize('com_ui_more_info')} + +
+ + )} +
+ ))} +
+
+ +
+
+
+
+
+ ); +}; + +export default AdminSettings; diff --git a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx new file mode 100644 index 000000000..1670ba6f6 --- /dev/null +++ b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { OGDialog, OGDialogTemplate, Button, Label, Input } from '~/components/ui'; +import { useCreateMemoryMutation } from '~/data-provider'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { Spinner } from '~/components/svg'; + +interface MemoryCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + triggerRef?: React.MutableRefObject; +} + +export default function MemoryCreateDialog({ + open, + onOpenChange, + children, + triggerRef, +}: MemoryCreateDialogProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + + const hasCreateAccess = useHasAccess({ + permissionType: PermissionTypes.MEMORIES, + permission: Permissions.CREATE, + }); + + const { mutate: createMemory, isLoading } = useCreateMemoryMutation({ + onSuccess: () => { + showToast({ + message: localize('com_ui_memory_created'), + status: 'success', + }); + onOpenChange(false); + setKey(''); + setValue(''); + setTimeout(() => { + triggerRef?.current?.focus(); + }, 0); + }, + onError: (error: Error) => { + let errorMessage = localize('com_ui_error'); + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + if (axiosError.response?.data?.error) { + errorMessage = axiosError.response.data.error; + + // Check for duplicate key error + if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { + errorMessage = localize('com_ui_memory_key_exists'); + } + } + } else if (error.message) { + errorMessage = error.message; + } + + showToast({ + message: errorMessage, + status: 'error', + }); + }, + }); + + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + + const handleSave = () => { + if (!hasCreateAccess) { + return; + } + + if (!key.trim() || !value.trim()) { + showToast({ + message: localize('com_ui_field_required'), + status: 'error', + }); + return; + } + + createMemory({ + key: key.trim(), + value: value.trim(), + }); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey && hasCreateAccess) { + handleSave(); + } + }; + + return ( + + {children} + +
+ + setKey(e.target.value)} + onKeyDown={handleKeyPress} + placeholder={localize('com_ui_enter_key')} + className="w-full" + /> +
+
+ +