Compare commits

..

58 Commits

Author SHA1 Message Date
Danny Avila
1e9fb86ba8 refactor: Optimize filter change handling and query invalidation in usePromptGroupsNav hook 2025-08-11 22:41:26 -04:00
Danny Avila
6137a089b3 refactor: Add PromptGroups context provider and integrate it into relevant components 2025-08-11 22:15:33 -04:00
Danny Avila
a928115a84 feat: Implement paginated access to prompt groups with filtering and public visibility 2025-08-11 22:06:53 -04:00
Danny Avila
1b14721e75 feat: Refactor prompt and prompt group schemas; move types to separate file 2025-08-11 22:06:15 -04:00
Danny Avila
f4301e3fb0 🗞️ refactor: Apply Role Permissions at Startup only if Missing or Configured 2025-08-11 19:00:54 -04:00
Danny Avila
fc71c6b358 🛒 feat: Implement Marketplace Permissions Management UI
- Added MarketplaceAdminSettings component for managing marketplace permissions.
- Updated roles.js to include marketplace permissions in the API.
- Refactored interface.js to streamline marketplace permissions handling.
- Enhanced Marketplace component to integrate admin settings.
- Updated localization files to include new marketplace-related keys.
- Added new API endpoint for updating marketplace permissions in data-service.
2025-08-11 19:00:54 -04:00
Danny Avila
3acb000090 🧑‍🤝‍🧑 feat: Add People Picker Permissions Management UI 2025-08-11 19:00:53 -04:00
Marco Beretta
f6924567ce 🖼️ style: Improve Marketplace & Sharing Dialog UI
feat: Enhance CategoryTabs and Marketplace components for better responsiveness and navigation

feat: Refactor AgentCard and AgentGrid components for improved layout and accessibility

feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout

feat: Refactor UI components for improved styling and accessibility in sharing dialogs

refactor: remove GenericManagePermissionsDialog and GrantAccessDialog components

- Deleted GenericManagePermissionsDialog and GrantAccessDialog components to streamline sharing functionality.
- Updated ManagePermissionsDialog to utilize AccessRolesPicker directly.
- Introduced UnifiedPeopleSearch for improved people selection experience.
- Enhanced PublicSharingToggle with InfoHoverCard for better user guidance.
- Adjusted AgentPanel to change error status to warning for duplicate agent versions.
- Updated translations to include new keys for search and access management.

feat: Add responsive design for SelectedPrincipalsList and improve layout in GenericGrantAccessDialog

feat: Enhance styling in SelectedPrincipalsList and SearchPicker components for improved UI consistency

feat: Improve PublicSharingToggle component with enhanced styling and accessibility features

feat: Introduce InfoHoverCard component and refactor enums for better organization

feat: Implement infinite scroll for agent grids and enhance performance

- Added `useInfiniteScroll` hook to manage infinite scrolling behavior in agent grids.
- Integrated infinite scroll functionality into `AgentGrid` and `VirtualizedAgentGrid` components.
- Updated `AgentMarketplace` to pass the scroll container to the agent grid components.
- Refactored loading indicators to show a spinner instead of a "Load More" button.
- Created `VirtualizedAgentGrid` component for optimized rendering of agent cards using virtualization.
- Added performance tests for `VirtualizedAgentGrid` to ensure efficient handling of large datasets.
- Updated translations to include new messages for end-of-results scenarios.

chore: Remove unused permission-related UI localization keys

ci: Update Agent model tests to handle duplicate support_contact updates

- Modified tests to ensure that updating an agent with the same support_contact does not create a new version and returns successfully.
- Enhanced verification for partial changes in support_contact, confirming no new version is created when content remains the same.

chore: Address ESLint, clean up unused imports and improve prop definitions in various components

ci: fix tests

ci: update tests

chore: remove unused search localization keys
2025-08-11 19:00:53 -04:00
Danny Avila
1fa055b4ec 🤖 fix: Edge Case for Agent List Access Control
- Refactored `getListAgentsByAccess` to streamline query construction for accessible agents.
- Added comprehensive security tests for `getListAgentsByAccess` and `getListAgentsHandler` to ensure proper access control and filtering based on user permissions.
- Enhanced test coverage for various scenarios, including pagination, category filtering, and handling of non-existent IDs.
2025-08-11 19:00:53 -04:00
Danny Avila
0e18faf44b 🎨 style: Theming in SharePointPickerDialog, PrincipalAvatar, and PeoplePickerSearchItem 2025-08-11 19:00:52 -04:00
Danny Avila
74474815aa 🛂 feat: Role as Permission Principal Type
WIP: Role as Permission Principal Type

WIP: add user role check optimization to user principal check, update type comparisons

WIP: cover edge cases for string vs ObjectId handling in permission granting and checking

chore: Update people picker access middleware to use PrincipalType constants

feat: Enhance people picker access control to include roles permissions

chore: add missing default role schema values for people picker perms, cleanup typing

feat: Enhance PeoplePicker component with role-specific UI and localization updates

chore: Add missing `VIEW_ROLES` permission to role schema
2025-08-11 19:00:52 -04:00
Danny Avila
5bec40e574 🔧 refactor: Integrate PrincipalModel Enum for Principal Handling
- Replaced string literals for principal models ('User', 'Group') with the new PrincipalModel enum across various models, services, and tests to enhance type safety and consistency.
- Updated permission handling in multiple files to utilize the PrincipalModel enum, improving maintainability and reducing potential errors.
- Ensured all relevant tests reflect these changes to maintain coverage and functionality.
2025-08-11 19:00:51 -04:00
Danny Avila
71a3d97058 🔧 refactor: Add and use PrincipalType Enum
- Replaced string literals for principal types ('user', 'group', 'public') with the new PrincipalType enum across various models, services, and tests for improved type safety and consistency.
- Updated permission handling in multiple files to utilize the PrincipalType enum, enhancing maintainability and reducing potential errors.
- Ensured all relevant tests reflect these changes to maintain coverage and functionality.
2025-08-11 19:00:51 -04:00
Danny Avila
a21891f692 🌏 chore: Remove unused localization keys from translation.json
- Deleted keys related to sharing prompts and public access that are no longer in use, streamlining the localization file.
2025-08-11 19:00:51 -04:00
Danny Avila
cbc38b8263 🧪 ci: Update PermissionService tests for PromptGroup resource type
- Refactor tests to use PromptGroup roles instead of Project roles.
- Initialize models and seed default roles in test setup.
- Update error handling for non-existent resource types.
- Ensure proper cleanup of test data while retaining seeded roles.
2025-08-11 19:00:50 -04:00
Danny Avila
83e2766187 🔗 fix: File Citation Processing to Use Tool Artifacts 2025-08-11 19:00:50 -04:00
Danny Avila
8d6110342f 🔧 refactor: Organize Sharing/Agent Components and Improve Type Safety
refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids, rename enums to PascalCase

refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids

chore: move sharing related components to dedicated "Sharing" directory

chore: remove PublicSharingToggle component and update index exports

chore: move non-sidepanel agent components to `~/components/Agents`

chore: move AgentCategoryDisplay component with tests

chore: remove commented out code

refactor: change PERMISSION_BITS from const to enum for better type safety

refactor: reorganize imports in GenericGrantAccessDialog and update index exports for hooks

refactor: update type definitions to use ACCESS_ROLE_IDS for improved type safety

refactor: remove unused canAccessPromptResource middleware and related code

refactor: remove unused prompt access roles from createAccessRoleMethods

refactor: update resourceType in AclEntry type definition to remove unused 'prompt' value

refactor: introduce ResourceType enum and update resourceType usage across data provider files for improved type safety

refactor: update resourceType usage to ResourceType enum across sharing and permissions components for improved type safety

refactor: standardize resourceType usage to ResourceType enum across agent and prompt models, permissions controller, and middleware for enhanced type safety

refactor: update resourceType references from PROMPT_GROUP to PROMPTGROUP for consistency across models, middleware, and components

refactor: standardize access role IDs and resource type usage across agent, file, and prompt models for improved type safety and consistency

chore: add typedefs for TUpdateResourcePermissionsRequest and TUpdateResourcePermissionsResponse to enhance type definitions

chore: move SearchPicker to PeoplePicker dir

refactor: implement debouncing for query changes in SearchPicker for improved performance

chore: fix typing, import order for agent admin settings

fix: agent admin settings, prevent agent form submission

refactor: rename `ACCESS_ROLE_IDS` to `AccessRoleIds`

refactor: replace PermissionBits with PERMISSION_BITS

refactor: replace PERMISSION_BITS with PermissionBits
2025-08-11 19:00:49 -04:00
Danny Avila
472c2f14e4 🗨️ feat: Granular Prompt Permissions via ACL and Permission Bits
feat: Implement prompt permissions management and access control middleware

fix: agent deletion process to remove associated permissions and ACL entries

fix: Import Permissions for enhanced access control in GrantAccessDialog

feat: use PromptGroup for access control

- Added migration script for PromptGroup permissions, categorizing groups into global view access and private groups.
- Created unit tests for the migration script to ensure correct categorization and permission granting.
- Introduced middleware for checking access permissions on PromptGroups and prompts via their groups.
- Updated routes to utilize new access control middleware for PromptGroups.
- Enhanced access role definitions to include roles specific to PromptGroups.
- Modified ACL entry schema and types to accommodate PromptGroup resource type.
- Updated data provider to include new access role identifiers for PromptGroups.

feat: add generic access management dialogs and hooks for resource permissions

fix: remove duplicate imports in FileContext component

fix: remove duplicate mongoose dependency in package.json

feat: add access permissions handling for dynamic resource types and add promptGroup roles

feat: implement centralized role localization and update access role types

refactor: simplify author handling in prompt group routes and enhance ACL checks

feat: implement addPromptToGroup functionality and update PromptForm to use it

feat: enhance permission handling in ChatGroupItem, DashGroupItem, and PromptForm components

chore: rename migration script for prompt group permissions and update package.json scripts

chore: update prompt tests
2025-08-11 19:00:49 -04:00
Danny Avila
8d51f450e8 🧹 chore: Add Back Agent-Specific File Retrieval and Deletion Permissions 2025-08-11 19:00:49 -04:00
“Praneeth
5a2eb74c2d 🔒 feat: Implement Granular File Storage Strategies and Access Control Middleware 2025-08-11 19:00:48 -04:00
Danny Avila
fc8e8d2a3b 🧪 ci: Update Test Files & fix ESLint issues 2025-08-11 19:00:48 -04:00
Danny Avila
d653e96209 🎨 style: Theming and Consistency Improvements for Agent Marketplace
style: AccessRolesPicker to use DropdownPopup, theming, import order, localization

refactor: Update localization keys for Agent Marketplace in NewChat component, remove duplicate key

style: Adjust layout and font size in NewChat component for Agent Marketplace button

style: theming in AgentGrid

style: Update theming and text colors across Agent components for improved consistency

chore: import order

style: Replace Dialog with OGDialog and update content components in AgentDetail

refactor: Simplify AgentDetail component by removing dropdown menu and replacing it with a copy link button

style: Enhance scrollbar visibility and layout in AgentMarketplace and CategoryTabs components

style: Adjust layout in AgentMarketplace component by removing unnecessary padding from the container

style: Refactor layout in AgentMarketplace component by reorganizing hero section and sticky wrapper for improved structure with collapsible header effect

style: Improve responsiveness and layout in AgentMarketplace component by adjusting header visibility and modifying container styles based on screen size

fix: Update localization key for no categories message in CategoryTabs component and corresponding test

style: Add className prop to OpenSidebar component for improved styling flexibility and update Header to utilize it for responsive design

style: Enhance layout and scrolling behavior in CategoryTabs component by adding scroll snap properties and adjusting class names for improved user experience

style: Update AgentGrid component layout and skeleton structure for improved visual consistency and responsiveness
2025-08-11 19:00:47 -04:00
“Praneeth
6bb6d2044b 🏪 feat: Agent Marketplace
bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status

refactored and moved agent category methods and schema to data-schema package

🔧 fix: Merge and Rebase Conflicts

- Move AgentCategory from api/models to @packages/data-schemas structure
  - Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script

refactor: unify agent marketplace to single endpoint with cursor pagination

  - Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests

feat: add icon property to ProcessedAgentCategory interface

- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
  - Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality

refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency

  - Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware

selected view only agents injected in the drop down

fix: remove minlength validation for support contact name in agent schema

feat: add validation and error messages for agent name in AgentConfig and AgentPanel

fix: update agent permission check logic in AgentPanel to simplify condition

Fix linting WIP

Fix Unit tests WIP

ESLint fixes

eslint fix

refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic

- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.

refactor: remove redundant properties from IAgent interface in agent schema

chore: update localization for agent detail component and clean up imports

ci: update access middleware tests

chore: remove unused PermissionTypes import from Role model

ci: update AclEntry model tests

ci: update button accessibility labels in AgentDetail tests

refactor: update exhaustive dep. lint warning

🔧 fix: Fixed agent actions access

feat: Add role-level permissions for agent sharing people picker

  - Add PEOPLE_PICKER permission type with VIEW_USERS and VIEW_GROUPS permissions
  - Create custom middleware for query-aware permission validation
  - Implement permission-based type filtering in PeoplePicker component
  - Hide people picker UI when user lacks permissions, show only public toggle
  - Support granular access: users-only, groups-only, or mixed search modes

refactor: Replace marketplace interface config with permission-based system

  - Add MARKETPLACE permission type to handle marketplace access control
  - Update interface configuration to use role-based marketplace settings (admin/user)
  - Replace direct marketplace boolean config with permission-based checks
  - Modify frontend components to use marketplace permissions instead of interface config
  - Update agent query hooks to use marketplace permissions for determining permission levels
  - Add marketplace configuration structure similar to peoplePicker in YAML config
  - Backend now sets MARKETPLACE permissions based on interface configuration
  - When marketplace enabled: users get agents with EDIT permissions in dropdown lists  (builder mode)
  - When marketplace disabled: users get agents with VIEW permissions  in dropdown lists (browse mode)

🔧 fix: Redirect to New Chat if No Marketplace Access and Required Agent Name Placeholder (#8213)

* Fix: Fix the redirect to new chat page if access to marketplace is denied

* Fixed the required agent name placeholder

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>

chore: fix tests, remove unnecessary imports

refactor: Implement permission checks for file access via agents

- Updated `hasAccessToFilesViaAgent` to utilize permission checks for VIEW and EDIT access.
- Replaced project-based access validation with permission-based checks.
- Enhanced tests to cover new permission logic and ensure proper access control for files associated with agents.
- Cleaned up imports and initialized models in test files for consistency.

refactor: Enhance test setup and cleanup for file access control

- Introduced modelsToCleanup array to track models added during tests for proper cleanup.
- Updated afterAll hooks in test files to ensure all collections are cleared and only added models are deleted.
- Improved consistency in model initialization across test files.
- Added comments for clarity on cleanup processes and test data management.

chore: Update Jest configuration and test setup for improved timeout handling

- Added a global test timeout of 30 seconds in jest.config.js.
- Configured jest.setTimeout in jestSetup.js to allow individual test overrides if needed.
- Enhanced test reliability by ensuring consistent timeout settings across all tests.

refactor: Implement file access filtering based on agent permissions

- Introduced `filterFilesByAgentAccess` function to filter files based on user access through agents.
- Updated `getFiles` and `primeFiles` functions to utilize the new filtering logic.
- Moved `hasAccessToFilesViaAgent` function from the File model to permission services, adjusting imports accordingly
- Enhanced tests to ensure proper access control and filtering behavior for files associated with agents.

fix: make support_contact field a nested object rather than a sub-document

refactor: Update support_contact field initialization in agent model

- Removed handling for empty support_contact object in createAgent function.
- Changed default value of support_contact in agent schema to undefined.

test: Add comprehensive tests for support_contact field handling and versioning

refactor: remove unused avatar upload mutation field and add informational toast for success

chore: add missing SidePanelProvider for AgentMarketplace and organize imports

fix: resolve agent selection race condition in marketplace HandleStartChat
- Set agent in localStorage before newConversation to prevent useSelectorEffects from auto-selecting previous agent

fix: resolve agent dropdown showing raw ID instead of agent info from URL

  - Add proactive agent fetching when agent_id is present in URL parameters
  - Inject fetched agent into agents cache so dropdowns display proper name/avatar
  - Use useAgentsMap dependency to ensure proper cache initialization timing
  - Prevents raw agent IDs from showing in UI when visiting shared agent links

Fix: Agents endpoint renamed to "My Agent" for less confusion with the Marketplace agents.

chore: fix ESLint issues and Test Mocks

ci: update permissions structure in loadDefaultInterface tests

- Refactored permissions for MEMORY and added new permissions for MARKETPLACE and PEOPLE_PICKER.
- Ensured consistent structure for permissions across different types.

feat:  support_contact validation to allow empty email strings
2025-08-11 19:00:47 -04:00
Danny Avila
76d75030b9 🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features

🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
2025-08-11 19:00:47 -04:00
Jordi Higuera
abc32e66ce 🍃 feat: Add MongoDB Connection Pool Configuration Options (#8537)
* 🔧 Feat: Add MongoDB connection pool configuration options to environment variables

* 🔧 feat: Add environment variables for automatic index creation and collection creation in MongoDB connection

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-08-11 19:00:46 -04:00
Danny Avila
2ee68f9e4a 📚 feat: Add Source Citations for File Search in Agents (#8652)
* feat: Source Citations for file_search in Agents

* Fix: Added citation limits and relevance score to app service. Removed duplicate tests

*  feat: implement Role-level toggle to optionally disable file Source Citation in Agents

* 🐛 fix: update mock for librechat-data-provider to include PermissionTypes and SystemRoles

---------

Co-authored-by: “Praneeth <praneeth.goparaju@slalom.com>
2025-08-11 19:00:46 -04:00
Danny Avila
e49b49af6c 📁 feat: Integrate SharePoint File Picker and Download Workflow (#8651)
* feat(sharepoint): integrate SharePoint file picker and download workflow
Introduces end‑to‑end SharePoint import support:
* Token exchange with Microsoft Graph and scope management (`useSharePointToken`)
* Re‑usable hooks: `useSharePointPicker`, `useSharePointDownload`,
  `useSharePointFileHandling`
* FileSearch dropdown now offers **From Local Machine** / **From SharePoint**
  sources and gracefully falls back when SharePoint is disabled
* Agent upload model, `AttachFileMenu`, and `DropdownPopup` extended for
  SharePoint files and sub‑menus
* Blurry overlay with progress indicator and `maxSelectionCount` limit during
  downloads
* Cache‑flush utility (`config/flush-cache.js`) supporting Redis & filesystem,
  with dry‑run and npm script
* Updated `SharePointIcon` (uses `currentColor`) and new i18n keys
* Bug fixes: placeholder syntax in progress message, picker event‑listener
  cleanup
* Misc style and performance optimizations

* Fix ESLint warnings

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-08-11 19:00:45 -04:00
Danny Avila
052e61b735 v0.8.0-rc2 (#9000) 2025-08-11 18:58:21 -04:00
Danny Avila
1ccac58403 🔒 fix: Provider Validation for Social, OpenID, SAML, and LDAP Logins (#8999)
* fix: social login provider crossover

* feat: Enhance OpenID login handling and add tests for provider validation

* refactor: authentication error handling to use ErrorTypes.AUTH_FAILED enum

* refactor: update authentication error handling in LDAP and SAML strategies to use ErrorTypes.AUTH_FAILED enum

* ci: Add validation for login with existing email and different provider in SAML strategy

chore: Add logging for existing users with different providers in LDAP, SAML, and Social Login strategies
2025-08-11 18:51:46 -04:00
Clay Rosenthal
04d74a7e07 🪖 ci: Helm OCI Publishing (#7256)
* Adding helm oci publishing (#3)

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update chart version

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update helm release

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

* Update helm chart package path

Signed-off-by: Clay Rosenthal <clayros@amazon.com>

---------

Signed-off-by: Clay Rosenthal <clayros@amazon.com>
2025-08-11 16:21:05 -04:00
github-actions[bot]
0fdca8ddbd 🌍 i18n: Update translation.json with latest translations (#8996)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-11 16:19:37 -04:00
Danny Avila
c5ca621efd 🧑‍💼 feat: Add Agent Model Validation (#8995)
* fix: Update logger import to use data-schemas module

* feat: agent model validation

* fix: Remove invalid error messages from translation file
2025-08-11 14:26:28 -04:00
Danny Avila
8cefa566da 📸 fix: Avatar Handling for Social Login (#8993)
- Updated `handleExistingUser` function to improve avatar handling logic, including checks for manual flags and null/undefined avatars.
- Introduced a new test suite for `handleExistingUser` covering various scenarios, ensuring robust functionality for avatar updates in both local and non-local storage contexts.
2025-08-11 11:50:46 -04:00
Danny Avila
7e4c8a5d0d 🛡️ fix: OTP Verification For 2FA Disable Operation (#8975) 2025-08-10 15:05:16 -04:00
Danny Avila
edf33bedcb 🛂 feat: Payload limits and Validation for User-created Memories (#8974) 2025-08-10 14:46:16 -04:00
Dustin Healy
21e00168b1 🪙 fix: Max Output Tokens Refactor for Responses API (#8972)
🪙 fix: Max Output Tokens Refactor for Responses API (#8972)

chore: Remove `max_output_tokens` from model kwargs in `titleConvo` if provided
2025-08-10 14:08:35 -04:00
github-actions[bot]
da3730b7d6 🌍 i18n: Update translation.json with latest translations (#8957)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-09 12:04:56 -04:00
Danny Avila
770c766d50 🔧 refactor: Move Plugin-related Helpers to TS API and Add Tests (#8961) 2025-08-09 12:02:44 -04:00
alkshmir
5eb6926464 🪖 chore: Fix Typo in helm/librechat/values.yaml (#8960) 2025-08-09 12:00:37 -04:00
Danny Avila
e478ae1c28 📦 chore: Bump @librechat/agents to v2.4.75 (#8956) 2025-08-09 00:01:21 -04:00
Danny Avila
0c9284c8ae 🧠 refactor: Memory Timeout after Completion and Guarantee Stream Final Event (#8955) 2025-08-09 00:01:10 -04:00
Danny Avila
4eeadddfe6 🔮 fix: Artifacts readOnly to Re-render when Expected (#8954) 2025-08-08 22:44:58 -04:00
Dustin Healy
9ca1847535 🔧 refactor: customUserVar Error Normalization (#8950)
* fix: localization string had unused template var

* fix: add normalizeHttpError to hopefully stop UI hangs when an error is returned in UserController

- Ensures updateUserPluginsController always returns valid HTTP status codes instead of undefined
- Add normalizeHttpError() helper to safely extract status/message from errors
- Default to 400 status code when Error.status is undefined/invalid

* refactor: move normalizeHttpError to packages/api
2025-08-08 15:53:04 -04:00
Danny Avila
5d0bc95193 🧪 fix: Editor Styling, Incomplete Artifact Editing, Optimize Artifact Context (#8953)
* refactor: optimize artifacts context for improved performance

* fix: layout classes for artifacts editor

* chore: linting

* fix: enhance artifact mutation handling in CodeEditor to prevent infinite retries

* fix: handle incomplete artifacts in replaceArtifactContent and add regression tests
2025-08-08 15:49:58 -04:00
github-actions[bot]
e7d6100fe4 🌍 i18n: Update translation.json with latest translations (#8934)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-08 12:21:27 -04:00
Danny Avila
01a95229f2 📦 chore: Bump @librechat/agents to v2.4.73 (#8949) 2025-08-08 12:19:36 -04:00
Danny Avila
0939250f07 🛣️ fix: Remove Title Tokens Limit for GPT-5 Models (#8948)
* 🛣️ fix: Remove Title Tokens Limit for GPT-5 Models

* 🛣️ fix: Remove max_completion_tokens from modelKwargs when maxTokens is disabled

* chore: Add test-image* to .gitignore for CI/CD data
2025-08-08 11:15:29 -04:00
Danny Avila
7147bce3c3 feat: Add OpenAI Verbosity Parameter (#8929)
* WIP: Verbosity OpenAI Parameter

* 🔧 chore: remove unused import of extractEnvVariable from parsers.ts

*  feat: add comprehensive tests for getOpenAIConfig and enhance verbosity handling

* fix: Handling for maxTokens in GPT-5+ models and add corresponding tests

* feat: Implement GPT-5+ model handling in processMemory function
2025-08-07 20:49:40 -04:00
github-actions[bot]
486fe34a2b 🌍 i18n: Update translation.json with latest translations (#8924)
* 🌍 i18n: Update translation.json with latest translations

* Update translation.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-08-07 20:42:44 -04:00
github-actions[bot]
922f43f520 🌍 i18n: Update translation.json with latest translations (#8907)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-07 16:25:24 -04:00
Marco Beretta
e6fa01d514 💬 style: Enhance Tooltip with HTML support and Improve Styling (#8915)
*  feat: Enhance Tooltip component with HTML support and styling improvements

*  feat: Integrate DOMPurify for HTML sanitization in Tooltip component
2025-08-07 16:24:42 -04:00
Danny Avila
8238fb49e0 📦 chore: bump @librechat/agents to v2.4.72 2025-08-07 16:23:12 -04:00
Danny Avila
430557676d feat: GPT-5 Token Limits, Rates, Icon, Reasoning Support 2025-08-07 16:23:11 -04:00
Danny Avila
8a5047c456 📦 chore: bump @librechat/agents to v2.4.71 2025-08-07 16:23:11 -04:00
Danny Avila
c787515894 🧠 feat: Add minimal Reasoning Effort option 2025-08-07 16:23:11 -04:00
Danny Avila
d95d8032cc feat: GPT-OSS models Token Limits & Rates 2025-08-07 16:23:10 -04:00
Joseph Licata
b9f72f4869 🎚️ refactor: Update Min. Values for OpenAI Parameters (#8922) 2025-08-07 14:38:08 -04:00
Danny Avila
429bb6653a 📦 chore: bump @librechat/agents to v2.4.70 (#8923) 2025-08-07 14:36:10 -04:00
193 changed files with 7541 additions and 2885 deletions

View File

@@ -4,12 +4,13 @@ name: Build Helm Charts on Tag
on:
push:
tags:
- "*"
- "chart-*"
jobs:
release:
permissions:
contents: write
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -26,15 +27,49 @@ jobs:
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Build Subchart Deps
run: |
cd helm/librechat-rag-api
helm dependency build
cd helm/librechat
helm dependency build
cd ../librechat-rag-api
helm dependency build
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
- name: Get Chart Version
id: chart-version
run: |
CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Run Helm OCI Charts Releaser
# This is for the librechat chart
- name: Release Helm OCI Charts for librechat
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat
repository: ${{ github.actor }}/librechat-chart
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat
registry: ghcr.io
registry_username: ${{ github.actor }}
registry_password: ${{ secrets.GITHUB_TOKEN }}
# this is for the librechat-rag-api chart
- name: Release Helm OCI Charts for librechat-rag-api
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat-rag-api
repository: ${{ github.actor }}/librechat-chart
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat-rag-api
registry: ghcr.io
registry_username: ${{ github.actor }}
registry_password: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@ pids
*.seed
.git
# CI/CD data
test-image*
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc1
# v0.8.0-rc2
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.0-rc1
# v0.8.0-rc2
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -1222,7 +1222,9 @@ ${convo}
}
if (this.isOmni === true && modelOptions.max_tokens != null) {
modelOptions.max_completion_tokens = modelOptions.max_tokens;
const paramName =
modelOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
modelOptions[paramName] = modelOptions.max_tokens;
delete modelOptions.max_tokens;
}
if (this.isOmni === true && modelOptions.temperature != null) {

View File

@@ -1346,18 +1346,21 @@ describe('models/Agent', () => {
expect(secondUpdate.versions).toHaveLength(3);
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
// Try to update with same support_contact - should be detected as duplicate
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
// Try to update with same support_contact - should be detected as duplicate but return successfully
const duplicateUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
),
).rejects.toThrow('Duplicate version');
},
);
// Should not create a new version
expect(duplicateUpdate.versions).toHaveLength(3);
expect(duplicateUpdate.version).toBe(3);
expect(duplicateUpdate.support_contact.email).toBe('updated@support.com');
});
test('should handle support_contact from empty to populated', async () => {
@@ -1541,18 +1544,22 @@ describe('models/Agent', () => {
expect(updated.support_contact.name).toBe('New Name');
expect(updated.support_contact.email).toBe('');
// Verify isDuplicateVersion works with partial changes
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '',
},
// Verify isDuplicateVersion works with partial changes - should return successfully without creating new version
const duplicateUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '',
},
),
).rejects.toThrow('Duplicate version');
},
);
// Should not create a new version since content is the same
expect(duplicateUpdate.versions).toHaveLength(2);
expect(duplicateUpdate.version).toBe(2);
expect(duplicateUpdate.support_contact.name).toBe('New Name');
expect(duplicateUpdate.support_contact.email).toBe('');
});
// Edge Cases

View File

@@ -247,10 +247,130 @@ const deletePromptGroup = async ({ _id, author, role }) => {
return { message: 'Prompt group deleted successfully' };
};
/**
* Get prompt groups by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible prompt groups.
* @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
* @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the prompt groups data and pagination info.
*/
async function getListPromptGroupsByAccess({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible prompt groups with other filters
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
// Build aggregation pipeline
const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }];
// Only apply limit if pagination is requested
if (isPaginated) {
pipeline.push({ $limit: normalizedLimit + 1 });
}
// Add lookup for production prompt
pipeline.push(
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
{
$project: {
name: 1,
numberOfGenerations: 1,
oneliner: 1,
category: 1,
projectIds: 1,
productionId: 1,
author: 1,
authorName: 1,
createdAt: 1,
updatedAt: 1,
'productionPrompt.prompt': 1,
},
},
);
const promptGroups = await PromptGroup.aggregate(pipeline).exec();
const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
(group) => {
if (group.author) {
group.author = group.author.toString();
}
return group;
},
);
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastGroup = promptGroups[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastGroup.updatedAt.toISOString(),
_id: lastGroup._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0]._id.toString() : null,
last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
has_more: hasMore,
after: nextCursor,
};
}
module.exports = {
getPromptGroups,
deletePromptGroup,
getAllPromptGroups,
getListPromptGroupsByAccess,
/**
* Create a prompt and its respective group
* @param {TCreatePromptRecord} saveData

View File

@@ -16,7 +16,7 @@ const { Role } = require('~/db/models');
*
* @param {string} roleName - The name of the role to find or create.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<Object>} A plain object representing the role document.
* @returns {Promise<IRole>} Role document.
*/
const getRoleByName = async function (roleName, fieldsToSelect = null) {
const cache = getLogStores(CacheKeys.ROLES);
@@ -72,8 +72,9 @@ const updateRoleByName = async function (roleName, updates) {
* Updates access permissions for a specific role and multiple permission types.
* @param {string} roleName - The role to update.
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
* @param {IRole} [roleData] - Optional role data to use instead of fetching from the database.
*/
async function updateAccessPermissions(roleName, permissionsUpdate) {
async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
// Filter and clean the permission updates based on our schema definition.
const updates = {};
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
@@ -86,7 +87,7 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
}
try {
const role = await getRoleByName(roleName);
const role = roleData ?? (await getRoleByName(roleName));
if (!role) {
return;
}
@@ -113,7 +114,6 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
}
}
// Process the current updates
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentTypePermissions = currentPermissions[permissionType] || {};
updatedPermissions[permissionType] = { ...currentTypePermissions };

View File

@@ -1,4 +1,4 @@
const { matchModelName } = require('../utils');
const { matchModelName } = require('../utils/tokens');
const defaultRate = 6;
/**
@@ -87,6 +87,9 @@ const tokenValues = Object.assign(
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.5': { prompt: 75, completion: 150 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-4o': { prompt: 2.5, completion: 10 },
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
@@ -147,6 +150,9 @@ const tokenValues = Object.assign(
codestral: { prompt: 0.3, completion: 0.9 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
'ministral-3b': { prompt: 0.04, completion: 0.04 },
// GPT-OSS models
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
},
bedrockValues,
);
@@ -214,6 +220,12 @@ const getValueKey = (model, endpoint) => {
return 'gpt-4.1';
} else if (modelName.includes('gpt-4o-2024-05-13')) {
return 'gpt-4o-2024-05-13';
} else if (modelName.includes('gpt-5-nano')) {
return 'gpt-5-nano';
} else if (modelName.includes('gpt-5-mini')) {
return 'gpt-5-mini';
} else if (modelName.includes('gpt-5')) {
return 'gpt-5';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {

View File

@@ -25,8 +25,14 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4-some-other-info')).toBe('8k');
});
it('should return undefined for model names that do not match any known patterns', () => {
expect(getValueKey('gpt-5-some-other-info')).toBeUndefined();
it('should return "gpt-5" for model name containing "gpt-5"', () => {
expect(getValueKey('gpt-5-some-other-info')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
});
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
@@ -84,6 +90,29 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4.1-nano-0125')).toBe('gpt-4.1-nano');
});
it('should return "gpt-5" for model type of "gpt-5"', () => {
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
});
it('should return "gpt-5-mini" for model type of "gpt-5-mini"', () => {
expect(getValueKey('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
expect(getValueKey('openai/gpt-5-mini')).toBe('gpt-5-mini');
expect(getValueKey('gpt-5-mini-0130')).toBe('gpt-5-mini');
expect(getValueKey('gpt-5-mini-2025-01-30-0130')).toBe('gpt-5-mini');
});
it('should return "gpt-5-nano" for model type of "gpt-5-nano"', () => {
expect(getValueKey('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
expect(getValueKey('openai/gpt-5-nano')).toBe('gpt-5-nano');
expect(getValueKey('gpt-5-nano-0130')).toBe('gpt-5-nano');
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@@ -207,6 +236,48 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-5', () => {
const valueKey = getValueKey('gpt-5-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5'].completion,
);
expect(getMultiplier({ model: 'gpt-5-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5', tokenType: 'completion' })).toBe(
tokenValues['gpt-5'].completion,
);
});
it('should return the correct multiplier for gpt-5-mini', () => {
const valueKey = getValueKey('gpt-5-mini-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-mini'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-mini'].completion,
);
expect(getMultiplier({ model: 'gpt-5-mini-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-mini'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-mini', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-mini'].completion,
);
});
it('should return the correct multiplier for gpt-5-nano', () => {
const valueKey = getValueKey('gpt-5-nano-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-nano'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-nano'].completion,
);
expect(getMultiplier({ model: 'gpt-5-nano-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-nano'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-nano', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-nano'].completion,
);
});
it('should return the correct multiplier for gpt-4o', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@@ -307,10 +378,22 @@ describe('getMultiplier', () => {
});
it('should return defaultRate if derived valueKey does not match any known patterns', () => {
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-5-some-other-info' })).toBe(
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-10-some-other-info' })).toBe(
defaultRate,
);
});
it('should return correct multipliers for GPT-OSS models', () => {
const models = ['gpt-oss-20b', 'gpt-oss-120b'];
models.forEach((key) => {
const expectedPrompt = tokenValues[key].prompt;
const expectedCompletion = tokenValues[key].completion;
expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
});
});
});
describe('AWS Bedrock Model Tests', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -49,7 +49,7 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.75",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",

View File

@@ -1,46 +0,0 @@
const { logger } = require('~/config');
//handle duplicates
const handleDuplicateKeyError = (err, res) => {
logger.error('Duplicate key error:', err.keyValue);
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
const code = 409;
res
.status(code)
.send({ messages: `An document with that ${field} already exists.`, fields: field });
};
//handle validation errors
const handleValidationError = (err, res) => {
logger.error('Validation error:', err.errors);
let errors = Object.values(err.errors).map((el) => el.message);
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
let code = 400;
if (errors.length > 1) {
errors = errors.join(' ');
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
} else {
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
}
};
module.exports = (err, _req, res, _next) => {
try {
if (err.name === 'ValidationError') {
return handleValidationError(err, res);
}
if (err.code && err.code == 11000) {
return handleDuplicateKeyError(err, res);
}
// Special handling for errors like SyntaxError
if (err.statusCode && err.body) {
return res.status(err.statusCode).send(err.body);
}
logger.error('ErrorController => error', err);
return res.status(500).send('An unknown error occurred.');
} catch (err) {
logger.error('ErrorController => processing error', err);
return res.status(500).send('Processing error in ErrorController.');
}
};

View File

@@ -5,6 +5,7 @@ const { logger } = require('~/config');
/**
* @param {ServerRequest} req
* @returns {Promise<TModelsConfig>} The models config.
*/
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);

View File

@@ -1,54 +1,16 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, AuthType, Constants } = require('librechat-data-provider');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
getToolkitKey,
checkPluginAuth,
filterUniquePlugins,
convertMCPToolsToPlugins,
} = require('@librechat/api');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const { getToolkitKey } = require('~/server/services/ToolService');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { availableTools } = require('~/app/clients/tools');
const { getLogStores } = require('~/cache');
/**
* Filters out duplicate plugins from the list of plugins.
*
* @param {TPlugin[]} plugins The list of plugins to filter.
* @returns {TPlugin[]} The list of plugins with duplicates removed.
*/
const filterUniquePlugins = (plugins) => {
const seen = new Set();
return plugins.filter((plugin) => {
const duplicate = seen.has(plugin.pluginKey);
seen.add(plugin.pluginKey);
return !duplicate;
});
};
/**
* Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values.
* Supports alternate authentication fields, allowing validation against multiple possible environment variables.
*
* @param {TPlugin} plugin The plugin object containing the authentication configuration.
* @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise.
*/
const checkPluginAuth = (plugin) => {
if (!plugin.authConfig || plugin.authConfig.length === 0) {
return false;
}
return plugin.authConfig.every((authFieldObj) => {
const authFieldOptions = authFieldObj.authField.split('||');
let isFieldAuthenticated = false;
for (const fieldOption of authFieldOptions) {
const envValue = process.env[fieldOption];
if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) {
isFieldAuthenticated = true;
break;
}
}
return isFieldAuthenticated;
});
};
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
@@ -143,9 +105,9 @@ const getAvailableTools = async (req, res) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId });
const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
if (cachedToolsArray && userPlugins) {
if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
res.status(200).json(dedupedTools);
return;
@@ -185,7 +147,9 @@ const getAvailableTools = async (req, res) => {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
Object.keys(toolDefinitions).some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
if (!isToolDefined && !isToolkit) {
continue;
@@ -235,58 +199,6 @@ const getAvailableTools = async (req, res) => {
}
};
/**
* Converts MCP function format tools to plugin format
* @param {Object} functionTools - Object with function format tools
* @param {Object} customConfig - Custom configuration for MCP servers
* @returns {Array} Array of plugin objects
*/
function convertMCPToolsToPlugins(functionTools, customConfig) {
const plugins = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin = {
name: parts[0], // Use the tool name without server suffix
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
// Build authConfig for MCP tools
if (!serverConfig?.customUserVars) {
plugin.authConfig = [];
plugins.push(plugin);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
plugins.push(plugin);
}
return plugins;
}
module.exports = {
getAvailableTools,
getAvailablePluginsController,

View File

@@ -28,19 +28,211 @@ jest.mock('~/config', () => ({
jest.mock('~/app/clients/tools', () => ({
availableTools: [],
toolkits: [],
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
getToolkitKey: jest.fn(),
checkPluginAuth: jest.fn(),
filterUniquePlugins: jest.fn(),
convertMCPToolsToPlugins: jest.fn(),
}));
// Import the actual module with the function we want to test
const { getAvailableTools } = require('./PluginController');
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
const {
filterUniquePlugins,
checkPluginAuth,
convertMCPToolsToPlugins,
getToolkitKey,
} = require('@librechat/api');
describe('PluginController', () => {
describe('plugin.icon behavior', () => {
let mockReq, mockRes, mockCache;
let mockReq, mockRes, mockCache;
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
});
describe('getAvailablePluginsController', () => {
beforeEach(() => {
mockReq.app = { locals: { filteredTools: [], includedTools: [] } };
});
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
expect(mockRes.json).toHaveBeenCalledWith([
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
]);
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValueOnce(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
});
it('should return cached plugins when available', async () => {
const cachedPlugins = [
{ name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
];
mockCache.get.mockResolvedValue(cachedPlugins);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).not.toHaveBeenCalled();
expect(checkPluginAuth).not.toHaveBeenCalled();
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
});
it('should filter plugins based on includedTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
mockReq.app.locals.includedTools = ['key1'];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
});
describe('getAvailableTools', () => {
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}server1`]: {
function: { name: 'tool1', description: 'Tool 1' },
},
};
const mockConvertedPlugins = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
},
];
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools);
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
filterUniquePlugins.mockImplementation((plugins) => plugins);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: mockUserTools,
customConfig: null,
});
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserPlugins = [
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
];
const mockManifestPlugins = [
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
];
mockCache.get.mockResolvedValue(mockManifestPlugins);
getCachedTools.mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
// Should be called to deduplicate the combined array
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
...mockUserPlugins,
...mockManifestPlugins,
]);
});
it('should use checkPluginAuth to verify authentication status', async () => {
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValue(true);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
await getAvailableTools(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
});
it('should use getToolkitKey for toolkit validation', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue('toolkit1');
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
toolkit1_function: true,
});
await getAvailableTools(mockReq, mockRes);
expect(getToolkitKey).toHaveBeenCalled();
});
});
describe('plugin.icon behavior', () => {
const callGetAvailableToolsWithMCPServer = async (mcpServers) => {
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue({ mcpServers });
@@ -50,7 +242,22 @@ describe('PluginController', () => {
function: { name: 'test-tool', description: 'A test tool' },
},
};
const mockConvertedPlugin = {
name: 'test-tool',
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
icon: mcpServers['test-server']?.iconPath,
authenticated: true,
authConfig: [],
};
getCachedTools.mockResolvedValueOnce(functionTools);
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getToolkitKey.mockReturnValue(undefined);
getCachedTools.mockResolvedValueOnce({
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
});
@@ -60,14 +267,6 @@ describe('PluginController', () => {
return responseData.find((tool) => tool.name === 'test-tool');
};
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
});
it('should set plugin.icon when iconPath is defined', async () => {
const mcpServers = {
'test-server': {
@@ -86,4 +285,236 @@ describe('PluginController', () => {
expect(testTool.icon).toBeUndefined();
});
});
describe('helper function integration', () => {
it('should properly handle MCP tools with custom user variables', async () => {
const customConfig = {
mcpServers: {
'test-server': {
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
},
},
},
};
// We need to test the actual flow where MCP manager tools are included
const mcpManagerTools = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
},
];
// Mock the MCP manager to return tools
const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig);
// First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({});
// Mock convertMCPToolsToPlugins to return empty array for user tools
convertMCPToolsToPlugins.mockReturnValue([]);
// Mock filterUniquePlugins to pass through
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
// Mock checkPluginAuth
checkPluginAuth.mockReturnValue(true);
// Second call returns tool definitions
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
// Find the MCP tool in the response
const mcpTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
);
// The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
expect(mcpTool).toBeDefined();
expect(mcpTool.authConfig).toEqual([
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
]);
expect(mcpTool.authenticated).toBe(false);
});
it('should handle error cases gracefully', async () => {
mockCache.get.mockRejectedValue(new Error('Cache error'));
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
});
});
describe('edge cases with undefined/null values', () => {
it('should handle undefined cache gracefully', async () => {
getLogStores.mockReturnValue(undefined);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
});
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: null,
customConfig: null,
});
});
it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(undefined);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(false);
// Mock getCachedTools to return undefined for both calls
getCachedTools.mockReset();
getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: undefined,
customConfig: null,
});
});
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
const userTools = {
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
};
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValue(userTools);
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
});
it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(true);
await getAvailableTools(mockReq, mockRes);
// With empty tool definitions, no tools should be in the final output
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle MCP tools without customUserVars', async () => {
const customConfig = {
mcpServers: {
'test-server': {
// No customUserVars defined
},
},
};
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}test-server`]: {
function: { name: 'tool1', description: 'Tool 1' },
},
};
mockCache.get.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(customConfig);
getCachedTools.mockResolvedValueOnce(mockUserTools);
const mockPlugin = {
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
authConfig: [],
};
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
// The actual implementation doesn't set authConfig on tools without customUserVars
expect(responseData[0].authConfig).toEqual([]);
});
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
mockReq.app = { locals: {} };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([]);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle toolkit with undefined toolDefinitions keys', async () => {
const mockToolkit = {
name: 'Toolkit1',
pluginKey: 'toolkit1',
description: 'Toolkit 1',
toolkit: true,
};
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue(undefined);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);
await getAvailableTools(mockReq, mockRes);
// Should handle null toolDefinitions gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
});
});

View File

@@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
/**
* Disable 2FA by clearing the stored secret and backup codes.
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
*/
const disable2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId);
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' });
}
if (user.twoFactorEnabled) {
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
} else {
return res
.status(400)
.json({ message: 'Either token or backup code is required to disable 2FA' });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid token or backup code' });
}
}
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
return res.status(200).json();
} catch (err) {

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { webSearchKeys, extractWebSearchEnvVars } = require('@librechat/api');
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
const {
getFiles,
updateUser,
@@ -89,8 +89,8 @@ const updateUserPluginsController = async (req, res) => {
if (userPluginsService instanceof Error) {
logger.error('[userPluginsService]', userPluginsService);
const { status, message } = userPluginsService;
res.status(status).send({ message });
const { status, message } = normalizeHttpError(userPluginsService);
return res.status(status).send({ message });
}
}
@@ -137,7 +137,7 @@ const updateUserPluginsController = async (req, res) => {
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
if (authService instanceof Error) {
logger.error('[authService]', authService);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
}
} else if (action === 'uninstall') {
@@ -151,7 +151,7 @@ const updateUserPluginsController = async (req, res) => {
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
} else {
// This handles:
@@ -163,7 +163,7 @@ const updateUserPluginsController = async (req, res) => {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = authService);
({ status, message } = normalizeHttpError(authService));
}
}
}
@@ -193,7 +193,8 @@ const updateUserPluginsController = async (req, res) => {
return res.status(status).send();
}
res.status(status).send({ message });
const normalized = normalizeHttpError({ status, message });
return res.status(normalized.status).send({ message: normalized.message });
} catch (err) {
logger.error('[updateUserPluginsController]', err);
return res.status(500).json({ message: 'Something went wrong.' });

View File

@@ -402,6 +402,34 @@ class AgentClient extends BaseClient {
return result;
}
/**
* Creates a promise that resolves with the memory promise result or undefined after a timeout
* @param {Promise<(TAttachment | null)[] | undefined>} memoryPromise - The memory promise to await
* @param {number} timeoutMs - Timeout in milliseconds (default: 3000)
* @returns {Promise<(TAttachment | null)[] | undefined>}
*/
async awaitMemoryWithTimeout(memoryPromise, timeoutMs = 3000) {
if (!memoryPromise) {
return;
}
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs),
);
const attachments = await Promise.race([memoryPromise, timeoutPromise]);
return attachments;
} catch (error) {
if (error.message === 'Memory processing timeout') {
logger.warn('[AgentClient] Memory processing timed out after 3 seconds');
} else {
logger.error('[AgentClient] Error processing memory:', error);
}
return;
}
}
/**
* @returns {Promise<string | undefined>}
*/
@@ -1002,11 +1030,9 @@ class AgentClient extends BaseClient {
});
try {
if (memoryPromise) {
const attachments = await memoryPromise;
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
await this.recordCollectedUsage({ context: 'message' });
@@ -1017,11 +1043,9 @@ class AgentClient extends BaseClient {
);
}
} catch (err) {
if (memoryPromise) {
const attachments = await memoryPromise;
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
@@ -1123,11 +1147,16 @@ class AgentClient extends BaseClient {
clientOptions.configuration = options.configOptions;
}
// Ensure maxTokens is set for non-o1 models
if (!/\b(o\d)\b/i.test(clientOptions.model) && !clientOptions.maxTokens) {
clientOptions.maxTokens = 75;
} else if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
const shouldRemoveMaxTokens = /\b(o\d|gpt-[5-9])\b/i.test(clientOptions.model);
if (shouldRemoveMaxTokens && clientOptions.maxTokens != null) {
delete clientOptions.maxTokens;
} else if (!shouldRemoveMaxTokens && !clientOptions.maxTokens) {
clientOptions.maxTokens = 75;
}
if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_completion_tokens != null) {
delete clientOptions.modelKwargs.max_completion_tokens;
} else if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_output_tokens != null) {
delete clientOptions.modelKwargs.max_output_tokens;
}
clientOptions = Object.assign(

View File

@@ -728,6 +728,239 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('getOptions method - GPT-5+ model handling', () => {
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-5',
},
};
mockReq = {
app: {
locals: {},
},
user: {
id: 'user-123',
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
client = new AgentClient(mockOptions);
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const clientOptions = {
model: 'gpt-6',
maxTokens: 1500,
temperature: 0.8,
modelKwargs: {
customParam: 'value',
},
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toEqual({
customParam: 'value',
max_completion_tokens: 1500,
});
});
it('should not modify maxTokens for non-GPT-5+ models', () => {
const clientOptions = {
model: 'gpt-4',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
// Should not be modified since it's GPT-4
expect(clientOptions.maxTokens).toBe(2048);
expect(clientOptions.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not transform if maxTokens is null or undefined', () => {
const testCases = [
{ model: 'gpt-5', maxTokens: null },
{ model: 'gpt-5', maxTokens: undefined },
{ model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
];
testCases.forEach(({ model, maxTokens }, index) => {
const clientOptions = {
model,
maxTokens,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (index < 2) {
// null or undefined cases
expect(clientOptions.maxTokens).toBe(maxTokens);
expect(clientOptions.modelKwargs).toBeUndefined();
} else {
// 0 case - should transform
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
}
});
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View File

@@ -233,6 +233,26 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
);
}
}
// Edge case: sendMessage completed but abort happened during sendCompletion
// We need to ensure a final event is sent
else if (!res.headersSent && !res.finished) {
logger.debug(
'[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
);
const finalResponse = { ...response };
finalResponse.error = true;
sendEvent(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: finalResponse,
error: { message: 'Request was aborted during completion' },
});
res.end();
}
// Save user message if needed
if (!client.skipSaveUserMessage) {

View File

@@ -8,14 +8,12 @@ const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { isEnabled, ErrorController } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
@@ -122,8 +120,7 @@ const startServer = async () => {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
// Add the error controller one more time after all routes
app.use(errorController);
app.use(ErrorController);
app.use((req, res) => {
res.set({

View File

@@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
if (response.status === 200) {
return; // App is healthy
}
} catch (error) {
} catch {
// Ignore connection errors during polling
}

View File

@@ -33,7 +33,7 @@ const validateModel = async (req, res, next) => {
return next();
}
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
const errorMessage = {

View File

@@ -13,6 +13,8 @@ const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const memoryPayloadLimit = express.json({ limit: '100kb' });
const checkMemoryRead = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.READ],
@@ -60,6 +62,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
const memoryConfig = req.app.locals?.memory;
const tokenLimit = memoryConfig?.tokenLimit;
const charLimit = memoryConfig?.charLimit || 10000;
let usagePercentage = null;
if (tokenLimit && tokenLimit > 0) {
@@ -70,6 +73,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
memories: sortedMemories,
totalTokens,
tokenLimit: tokenLimit || null,
charLimit,
usagePercentage,
});
} catch (error) {
@@ -83,7 +87,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
* Body: { key: string, value: string }
* Returns 201 and { created: true, memory: <createdDoc> } when successful.
*/
router.post('/', checkMemoryCreate, async (req, res) => {
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
const { key, value } = req.body;
if (typeof key !== 'string' || key.trim() === '') {
@@ -94,13 +98,25 @@ router.post('/', checkMemoryCreate, async (req, res) => {
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
}
const memoryConfig = req.app.locals?.memory;
const charLimit = memoryConfig?.charLimit || 10000;
if (key.length > 1000) {
return res.status(400).json({
error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
});
}
if (value.length > charLimit) {
return res.status(400).json({
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
});
}
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) {
@@ -175,7 +191,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
* Body: { key?: string, value: string }
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
*/
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => {
const { key: urlKey } = req.params;
const { key: bodyKey, value } = req.body || {};
@@ -183,9 +199,23 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
}
// Use the key from the body if provided, otherwise use the key from the URL
const newKey = bodyKey || urlKey;
const memoryConfig = req.app.locals?.memory;
const charLimit = memoryConfig?.charLimit || 10000;
if (newKey.length > 1000) {
return res.status(400).json({
error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
});
}
if (value.length > charLimit) {
return res.status(400).json({
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
});
}
try {
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
@@ -196,7 +226,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(404).json({ error: 'Memory not found.' });
}
// If the key is changing, we need to handle it specially
if (newKey !== urlKey) {
const keyExists = memories.find((m) => m.key === newKey);
if (keyExists) {
@@ -219,7 +248,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
return res.status(500).json({ error: 'Failed to delete old memory.' });
}
} else {
// Key is not changing, just update the value
const result = await setMemory({
userId: req.user.id,
key: newKey,

View File

@@ -1,7 +1,10 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { isEnabled } = require('@librechat/api');
const { randomState } = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const {
checkBan,
logHeaders,
@@ -11,8 +14,6 @@ const {
} = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const router = express.Router();
@@ -48,13 +49,13 @@ const oauthHandler = async (req, res) => {
};
router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails.
/** A single error message is pushed by passport when authentication fails. */
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
logger.error('Error in OAuth authentication:', {
message: req.session?.messages?.pop() || 'Unknown error',
message: errorMessage,
});
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
res.redirect(`${domains.client}/login?redirect=false`);
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
});
/**

View File

@@ -1,6 +1,13 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const {
generateCheckAccess,
markPublicPromptGroups,
buildPromptGroupFilter,
formatPromptGroupsResponse,
createEmptyPromptGroupsResponse,
filterAccessibleIdsBySharedLogic,
} = require('@librechat/api');
const {
Permissions,
SystemRoles,
@@ -11,12 +18,11 @@ const {
PermissionTypes,
} = require('librechat-data-provider');
const {
getListPromptGroupsByAccess,
makePromptProduction,
getAllPromptGroups,
updatePromptGroup,
deletePromptGroup,
createPromptGroup,
getPromptGroups,
getPromptGroup,
deletePrompt,
getPrompts,
@@ -95,23 +101,48 @@ router.get(
router.get('/all', async (req, res) => {
try {
const userId = req.user.id;
const { name, category, ...otherFilters } = req.query;
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name,
category,
...otherFilters,
});
// Get promptGroup IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
let accessibleIds = await findAccessibleResources({
userId,
role: req.user.role,
resourceType: ResourceType.PROMPTGROUP,
requiredPermissions: PermissionBits.VIEW,
});
const groups = await getAllPromptGroups(req, {});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: ResourceType.PROMPTGROUP,
requiredPermissions: PermissionBits.VIEW,
});
// Filter the results to only include accessible groups
const accessibleGroups = groups.filter((group) =>
accessibleIds.some((id) => id.toString() === group._id.toString()),
);
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
accessibleIds,
searchShared,
searchSharedOnly,
publicPromptGroupIds: publiclyAccessibleIds,
});
res.status(200).send(accessibleGroups);
const result = await getListPromptGroupsByAccess({
accessibleIds: filteredAccessibleIds,
otherParams: filter,
});
if (!result) {
return res.status(200).send([]);
}
const { data: promptGroups = [] } = result;
if (!promptGroups.length) {
return res.status(200).send([]);
}
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
res.status(200).send(groupsWithPublicFlag);
} catch (error) {
logger.error(error);
res.status(500).send({ error: 'Error getting prompt groups' });
@@ -125,40 +156,66 @@ router.get('/all', async (req, res) => {
router.get('/groups', async (req, res) => {
try {
const userId = req.user.id;
const filter = { ...req.query };
delete filter.author; // Remove author filter as we'll use ACL
const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query;
// Get promptGroup IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name,
category,
...otherFilters,
});
let actualLimit = limit;
let actualCursor = cursor;
if (pageSize && !limit) {
actualLimit = parseInt(pageSize, 10);
}
let accessibleIds = await findAccessibleResources({
userId,
role: req.user.role,
resourceType: ResourceType.PROMPTGROUP,
requiredPermissions: PermissionBits.VIEW,
});
// Get publicly accessible promptGroups
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: ResourceType.PROMPTGROUP,
requiredPermissions: PermissionBits.VIEW,
});
const groups = await getPromptGroups(req, filter);
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
accessibleIds,
searchShared,
searchSharedOnly,
publicPromptGroupIds: publiclyAccessibleIds,
});
if (groups.promptGroups && groups.promptGroups.length > 0) {
groups.promptGroups = groups.promptGroups.filter((group) =>
accessibleIds.some((id) => id.toString() === group._id.toString()),
);
const result = await getListPromptGroupsByAccess({
accessibleIds: filteredAccessibleIds,
otherParams: filter,
limit: actualLimit,
after: actualCursor,
});
// Mark public groups
groups.promptGroups = groups.promptGroups.map((group) => {
if (publiclyAccessibleIds.some((id) => id.equals(group._id))) {
group.isPublic = true;
}
return group;
});
if (!result) {
const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit });
return res.status(200).send(emptyResponse);
}
res.status(200).send(groups);
const { data: promptGroups = [], has_more = false, after = null } = result;
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
const response = formatPromptGroupsResponse({
promptGroups: groupsWithPublicFlag,
pageNumber,
pageSize,
actualLimit,
hasMore: has_more,
after,
});
res.status(200).send(response);
} catch (error) {
logger.error(error);
res.status(500).send({ error: 'Error getting prompt groups' });
@@ -188,7 +245,6 @@ const createNewPromptGroup = async (req, res) => {
const result = await createPromptGroup(saveData);
// Grant owner permissions to the creator on the new promptGroup
if (result.prompt && result.prompt._id && result.prompt.groupId) {
try {
await grantPermission({

View File

@@ -1,11 +1,13 @@
const express = require('express');
const {
SystemRoles,
roleDefaults,
PermissionTypes,
agentPermissionsSchema,
promptPermissionsSchema,
memoryPermissionsSchema,
agentPermissionsSchema,
PermissionTypes,
roleDefaults,
SystemRoles,
marketplacePermissionsSchema,
peoplePickerPermissionsSchema,
} = require('librechat-data-provider');
const { checkAdmin, requireJwtAuth } = require('~/server/middleware');
const { updateRoleByName, getRoleByName } = require('~/models/Role');
@@ -13,6 +15,81 @@ const { updateRoleByName, getRoleByName } = require('~/models/Role');
const router = express.Router();
router.use(requireJwtAuth);
/**
* Permission configuration mapping
* Maps route paths to their corresponding schemas and permission types
*/
const permissionConfigs = {
prompts: {
schema: promptPermissionsSchema,
permissionType: PermissionTypes.PROMPTS,
errorMessage: 'Invalid prompt permissions.',
},
agents: {
schema: agentPermissionsSchema,
permissionType: PermissionTypes.AGENTS,
errorMessage: 'Invalid agent permissions.',
},
memories: {
schema: memoryPermissionsSchema,
permissionType: PermissionTypes.MEMORIES,
errorMessage: 'Invalid memory permissions.',
},
'people-picker': {
schema: peoplePickerPermissionsSchema,
permissionType: PermissionTypes.PEOPLE_PICKER,
errorMessage: 'Invalid people picker permissions.',
},
marketplace: {
schema: marketplacePermissionsSchema,
permissionType: PermissionTypes.MARKETPLACE,
errorMessage: 'Invalid marketplace permissions.',
},
};
/**
* Generic handler for updating permissions
* @param {string} permissionKey - The key from permissionConfigs
* @returns {Function} Express route handler
*/
const createPermissionUpdateHandler = (permissionKey) => {
const config = permissionConfigs[permissionKey];
return async (req, res) => {
const { roleName: _r } = req.params;
// TODO: TEMP, use a better parsing for roleName
const roleName = _r.toUpperCase();
const updates = req.body;
try {
const parsedUpdates = config.schema.partial().parse(updates);
const role = await getRoleByName(roleName);
if (!role) {
return res.status(404).send({ message: 'Role not found' });
}
const currentPermissions =
role.permissions?.[config.permissionType] || role[config.permissionType] || {};
const mergedUpdates = {
permissions: {
...role.permissions,
[config.permissionType]: {
...currentPermissions,
...parsedUpdates,
},
},
};
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
res.status(200).send(updatedRole);
} catch (error) {
return res.status(400).send({ message: config.errorMessage, error: error.errors });
}
};
};
/**
* GET /api/roles/:roleName
* Get a specific role by name
@@ -45,117 +122,30 @@ router.get('/:roleName', async (req, res) => {
* PUT /api/roles/:roleName/prompts
* Update prompt permissions for a specific role
*/
router.put('/:roleName/prompts', checkAdmin, async (req, res) => {
const { roleName: _r } = req.params;
// TODO: TEMP, use a better parsing for roleName
const roleName = _r.toUpperCase();
/** @type {TRole['permissions']['PROMPTS']} */
const updates = req.body;
try {
const parsedUpdates = promptPermissionsSchema.partial().parse(updates);
const role = await getRoleByName(roleName);
if (!role) {
return res.status(404).send({ message: 'Role not found' });
}
const currentPermissions =
role.permissions?.[PermissionTypes.PROMPTS] || role[PermissionTypes.PROMPTS] || {};
const mergedUpdates = {
permissions: {
...role.permissions,
[PermissionTypes.PROMPTS]: {
...currentPermissions,
...parsedUpdates,
},
},
};
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
res.status(200).send(updatedRole);
} catch (error) {
return res.status(400).send({ message: 'Invalid prompt permissions.', error: error.errors });
}
});
router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts'));
/**
* PUT /api/roles/:roleName/agents
* Update agent permissions for a specific role
*/
router.put('/:roleName/agents', checkAdmin, async (req, res) => {
const { roleName: _r } = req.params;
// TODO: TEMP, use a better parsing for roleName
const roleName = _r.toUpperCase();
/** @type {TRole['permissions']['AGENTS']} */
const updates = req.body;
try {
const parsedUpdates = agentPermissionsSchema.partial().parse(updates);
const role = await getRoleByName(roleName);
if (!role) {
return res.status(404).send({ message: 'Role not found' });
}
const currentPermissions =
role.permissions?.[PermissionTypes.AGENTS] || role[PermissionTypes.AGENTS] || {};
const mergedUpdates = {
permissions: {
...role.permissions,
[PermissionTypes.AGENTS]: {
...currentPermissions,
...parsedUpdates,
},
},
};
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
res.status(200).send(updatedRole);
} catch (error) {
return res.status(400).send({ message: 'Invalid agent permissions.', error: error.errors });
}
});
router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents'));
/**
* 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;
router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories'));
try {
const parsedUpdates = memoryPermissionsSchema.partial().parse(updates);
/**
* PUT /api/roles/:roleName/people-picker
* Update people picker permissions for a specific role
*/
router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
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 });
}
});
/**
* PUT /api/roles/:roleName/marketplace
* Update marketplace permissions for a specific role
*/
router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace'));
module.exports = router;

View File

@@ -5,7 +5,7 @@ jest.mock('~/models', () => ({
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),
getRoleByName: jest.fn(),
getRoleByName: jest.fn().mockResolvedValue(null),
updateRoleByName: jest.fn(),
}));
@@ -94,109 +94,71 @@ describe('AppService interface configuration', () => {
mockLoadCustomConfig.mockResolvedValue({
interface: {
peoplePicker: {
admin: {
users: true,
groups: true,
roles: true,
},
user: {
users: false,
groups: false,
roles: false,
},
users: true,
groups: true,
roles: true,
},
},
});
loadDefaultInterface.mockResolvedValue({
peoplePicker: {
admin: {
users: true,
groups: true,
roles: true,
},
user: {
users: false,
groups: false,
roles: false,
},
users: true,
groups: true,
roles: true,
},
});
await AppService(app);
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
expect(app.locals.interfaceConfig.peoplePicker.admin).toMatchObject({
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({
users: true,
groups: true,
roles: true,
});
expect(app.locals.interfaceConfig.peoplePicker.user).toMatchObject({
users: false,
groups: false,
roles: false,
});
expect(loadDefaultInterface).toHaveBeenCalled();
});
it('should handle mixed peoplePicker permissions for roles', async () => {
it('should handle mixed peoplePicker permissions', async () => {
mockLoadCustomConfig.mockResolvedValue({
interface: {
peoplePicker: {
admin: {
users: true,
groups: true,
roles: false,
},
user: {
users: true,
groups: false,
roles: true,
},
users: true,
groups: false,
roles: true,
},
},
});
loadDefaultInterface.mockResolvedValue({
peoplePicker: {
admin: {
users: true,
groups: true,
roles: false,
},
user: {
users: true,
groups: false,
roles: true,
},
users: true,
groups: false,
roles: true,
},
});
await AppService(app);
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(false);
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(false);
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
});
it('should set default peoplePicker roles permissions when not provided', async () => {
it('should set default peoplePicker permissions when not provided', async () => {
mockLoadCustomConfig.mockResolvedValue({});
loadDefaultInterface.mockResolvedValue({
peoplePicker: {
admin: {
users: true,
groups: true,
roles: true,
},
user: {
users: false,
groups: false,
roles: false,
},
users: true,
groups: true,
roles: true,
},
});
await AppService(app);
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(false);
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
});
});

View File

@@ -1,9 +1,8 @@
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
const {
FileSources,
loadOCRConfig,
EModelEndpoint,
loadMemoryConfig,
getConfigDefaults,
} = require('librechat-data-provider');
const {

View File

@@ -33,6 +33,7 @@ jest.mock('~/models', () => ({
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),
getRoleByName: jest.fn().mockResolvedValue(null),
}));
jest.mock('./Config', () => ({
setCachedTools: jest.fn(),
@@ -970,20 +971,13 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals.ocr.mistralModel).toEqual('mistral-medium');
});
it('should correctly configure peoplePicker with roles permission when specified', async () => {
it('should correctly configure peoplePicker permissions when specified', async () => {
const mockConfig = {
interface: {
peoplePicker: {
admin: {
users: true,
groups: true,
roles: true,
},
user: {
users: false,
groups: false,
roles: true,
},
users: true,
groups: true,
roles: true,
},
},
};
@@ -993,21 +987,16 @@ describe('AppService updating app.locals and issuing warnings', () => {
const app = { locals: {} };
await AppService(app);
// Check that interface config includes the roles permission
// Check that interface config includes the permissions
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
expect(app.locals.interfaceConfig.peoplePicker.admin).toMatchObject({
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({
users: true,
groups: true,
roles: true,
});
expect(app.locals.interfaceConfig.peoplePicker.user).toMatchObject({
users: false,
groups: false,
roles: true,
});
});
it('should use default peoplePicker roles permissions when not specified', async () => {
it('should use default peoplePicker permissions when not specified', async () => {
const mockConfig = {
interface: {
// No peoplePicker configuration
@@ -1019,9 +1008,10 @@ describe('AppService updating app.locals and issuing warnings', () => {
const app = { locals: {} };
await AppService(app);
// Check that default roles permissions are applied
// Check that default permissions are applied
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(false);
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true);
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
});
});

View File

@@ -60,7 +60,14 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
// Find boundaries between ARTIFACT_START and ARTIFACT_END
const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1;
const contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
let contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
// Special case: if contentEnd is 0, it means the only ::: found is at the start of :::artifact
// This indicates an incomplete artifact (no closing :::)
// We need to check that it's exactly at position 0 (the beginning of artifactContent)
if (contentEnd === 0 && artifactContent.indexOf(ARTIFACT_START) === 0) {
contentEnd = artifactContent.length;
}
if (contentStart === -1 || contentEnd === -1) {
return null;
@@ -72,12 +79,20 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
// Determine where to look for the original content
let searchStart, searchEnd;
if (codeBlockStart !== -1 && codeBlockEnd !== -1) {
// If code blocks exist, search between them
if (codeBlockStart !== -1) {
// Code block starts
searchStart = codeBlockStart + 4; // after ```\n
searchEnd = codeBlockEnd;
if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) {
// Code block has proper ending
searchEnd = codeBlockEnd;
} else {
// No closing backticks found or they're before the opening (shouldn't happen)
// This might be an incomplete artifact - search to contentEnd
searchEnd = contentEnd;
}
} else {
// Otherwise search in the whole artifact content
// No code blocks at all
searchStart = contentStart;
searchEnd = contentEnd;
}

View File

@@ -89,9 +89,9 @@ describe('replaceArtifactContent', () => {
};
test('should replace content within artifact boundaries', () => {
const original = 'console.log(\'hello\')';
const original = "console.log('hello')";
const artifact = createTestArtifact(original);
const updated = 'console.log(\'updated\')';
const updated = "console.log('updated')";
const result = replaceArtifactContent(artifact.text, artifact, original, updated);
expect(result).toContain(updated);
@@ -317,4 +317,182 @@ console.log(greeting);`;
expect(result).not.toContain('\n\n```');
expect(result).not.toContain('```\n\n');
});
describe('incomplete artifacts', () => {
test('should handle incomplete artifacts (missing closing ::: and ```)', () => {
const original = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pomodoro</title>
<meta name="description" content="A single-file Pomodoro timer with logs, charts, sounds, and dark mode." />
<style>
:root{`;
const prefix = `Awesome idea! I'll deliver a complete single-file HTML app called "Pomodoro" with:
- Custom session/break durations
You can save this as pomodoro.html and open it directly in your browser.
`;
// This simulates the real incomplete artifact case - no closing ``` or :::
const incompleteArtifact = `${ARTIFACT_START}{identifier="pomodoro-single-file-app" type="text/html" title="Pomodoro — Single File App"}
\`\`\`
${original}`;
const fullText = prefix + incompleteArtifact;
const message = { text: fullText };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
expect(artifacts[0].end).toBe(fullText.length);
const updated = original.replace('Pomodoro</title>', 'Pomodoro</title>UPDATED');
const result = replaceArtifactContent(fullText, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
expect(result).toContain(prefix);
// Should not have added closing markers
expect(result).not.toMatch(/:::\s*$/);
});
test('should handle incomplete artifacts with only opening code block', () => {
const original = 'function hello() { console.log("world"); }';
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n\`\`\`\n${original}`;
const message = { text: incompleteArtifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'function hello() { console.log("UPDATED"); }';
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
});
test('should handle incomplete artifacts without code blocks', () => {
const original = 'Some plain text content';
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`;
const message = { text: incompleteArtifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'Some UPDATED text content';
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('UPDATED');
});
});
describe('regression tests for edge cases', () => {
test('should still handle complete artifacts correctly', () => {
// Ensure we didn't break normal artifact handling
const original = 'console.log("test");';
const artifact = createArtifactText({ content: original });
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'console.log("updated");';
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain(updated);
expect(result).toContain(ARTIFACT_END);
expect(result).toMatch(/```\nconsole\.log\("updated"\);\n```/);
});
test('should handle multiple complete artifacts', () => {
// Ensure multiple artifacts still work
const content1 = 'First artifact';
const content2 = 'Second artifact';
const text = `${createArtifactText({ content: content1 })}\n\n${createArtifactText({ content: content2 })}`;
const message = { text };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(2);
// Update first artifact
const result1 = replaceArtifactContent(text, artifacts[0], content1, 'First UPDATED');
expect(result1).not.toBeNull();
expect(result1).toContain('First UPDATED');
expect(result1).toContain(content2);
// Update second artifact
const result2 = replaceArtifactContent(text, artifacts[1], content2, 'Second UPDATED');
expect(result2).not.toBeNull();
expect(result2).toContain(content1);
expect(result2).toContain('Second UPDATED');
});
test('should not mistake ::: at position 0 for artifact end in complete artifacts', () => {
// This tests the specific fix - ensuring contentEnd=0 doesn't break complete artifacts
const original = 'test content';
// Create an artifact that will have ::: at position 0 when substring'd
const artifact = `${ARTIFACT_START}\n\`\`\`\n${original}\n\`\`\`\n${ARTIFACT_END}`;
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
const updated = 'updated content';
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain(updated);
expect(result).toContain(ARTIFACT_END);
});
test('should handle empty artifacts', () => {
// Edge case: empty artifact
const artifact = `${ARTIFACT_START}\n${ARTIFACT_END}`;
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
// Trying to replace non-existent content should return null
const result = replaceArtifactContent(artifact, artifacts[0], 'something', 'updated');
expect(result).toBeNull();
});
test('should preserve whitespace and formatting in complete artifacts', () => {
const original = ` function test() {
return {
value: 42
};
}`;
const artifact = createArtifactText({ content: original });
const message = { text: artifact };
const artifacts = findAllArtifacts(message);
const updated = ` function test() {
return {
value: 100
};
}`;
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('value: 100');
// Should preserve exact formatting
expect(result).toMatch(
/```\n {2}function test\(\) \{\n {4}return \{\n {6}value: 100\n {4}\};\n {2}\}\n```/,
);
});
});
});

View File

@@ -1,12 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { useAzurePlugins } = require('~/server/services/Config/EndpointService').config;
const {
getAnthropicModels,
getBedrockModels,
getOpenAIModels,
getGoogleModels,
getBedrockModels,
getAnthropicModels,
} = require('~/server/services/ModelService');
const { logger } = require('~/config');
/**
* Loads the default models for the application.
@@ -16,58 +15,42 @@ const { logger } = require('~/config');
*/
async function loadDefaultModels(req) {
try {
const [
openAI,
anthropic,
azureOpenAI,
gptPlugins,
assistants,
azureAssistants,
google,
bedrock,
] = await Promise.all([
getOpenAIModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching OpenAI models:', error);
return [];
}),
getAnthropicModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching Anthropic models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch(
(error) => {
logger.error('Error fetching Plugin models:', error);
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
await Promise.all([
getOpenAIModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching OpenAI models:', error);
return [];
},
),
getOpenAIModels({ assistants: true }).catch((error) => {
logger.error('Error fetching OpenAI Assistants API models:', error);
return [];
}),
getOpenAIModels({ azureAssistants: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
return [];
}),
Promise.resolve(getGoogleModels()).catch((error) => {
logger.error('Error getting Google models:', error);
return [];
}),
Promise.resolve(getBedrockModels()).catch((error) => {
logger.error('Error getting Bedrock models:', error);
return [];
}),
]);
}),
getAnthropicModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching Anthropic models:', error);
return [];
}),
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI models:', error);
return [];
}),
getOpenAIModels({ assistants: true }).catch((error) => {
logger.error('Error fetching OpenAI Assistants API models:', error);
return [];
}),
getOpenAIModels({ azureAssistants: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
return [];
}),
Promise.resolve(getGoogleModels()).catch((error) => {
logger.error('Error getting Google models:', error);
return [];
}),
Promise.resolve(getBedrockModels()).catch((error) => {
logger.error('Error getting Bedrock models:', error);
return [];
}),
]);
return {
[EModelEndpoint.openAI]: openAI,
[EModelEndpoint.agents]: openAI,
[EModelEndpoint.google]: google,
[EModelEndpoint.anthropic]: anthropic,
[EModelEndpoint.gptPlugins]: gptPlugins,
[EModelEndpoint.azureOpenAI]: azureOpenAI,
[EModelEndpoint.assistants]: assistants,
[EModelEndpoint.azureAssistants]: azureAssistants,

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider');
const { loadAgent } = require('~/models/Agent');
const { logger } = require('~/config');
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;

View File

@@ -1,4 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { validateAgentModel } = require('@librechat/api');
const { createContentAggregator } = require('@librechat/agents');
const {
Constants,
@@ -11,10 +12,12 @@ const {
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getCustomEndpointConfig } = require('~/server/services/Config');
const { loadAgentTools } = require('~/server/services/ToolService');
const AgentClient = require('~/server/controllers/agents/client');
const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache');
function createToolLoader() {
/**
@@ -72,6 +75,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
throw new Error('Agent not found');
}
const modelsConfig = await getModelsConfig(req);
const validationResult = await validateAgentModel({
req,
res,
modelsConfig,
logViolation,
agent: primaryAgent,
});
if (!validationResult.isValid) {
throw new Error(validationResult.error?.message);
}
const agentConfigs = new Map();
/** @type {Set<string>} */
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
@@ -101,6 +117,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const validationResult = await validateAgentModel({
req,
res,
agent,
modelsConfig,
logViolation,
});
if (!validationResult.isValid) {
throw new Error(validationResult.error?.message);
}
const config = await initializeAgent({
req,
res,

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { getToolkitKey } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { Calculator } = require('@langchain/community/tools/calculator');
@@ -11,7 +12,6 @@ const {
ErrorTypes,
ContentTypes,
imageGenTools,
EToolResources,
EModelEndpoint,
actionDelimiter,
ImageVisionTool,
@@ -40,30 +40,6 @@ const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
/**
* @param {string} toolName
* @returns {string | undefined} toolKey
*/
function getToolkitKey(toolName) {
/** @type {string|undefined} */
let toolkitKey;
for (const toolkit of toolkits) {
if (toolName.startsWith(EToolResources.image_edit)) {
const splitMatches = toolkit.pluginKey.split('_');
const suffix = splitMatches[splitMatches.length - 1];
if (toolName.endsWith(suffix)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
if (toolName.startsWith(toolkit.pluginKey)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
return toolkitKey;
}
/**
* Loads and formats tools from the specified tool directory.
*
@@ -145,7 +121,7 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] })
for (const toolInstance of basicToolInstances) {
const formattedTool = formatToOpenAIAssistantTool(toolInstance);
let toolName = formattedTool[Tools.function].name;
toolName = getToolkitKey(toolName) ?? toolName;
toolName = getToolkitKey({ toolkits, toolName }) ?? toolName;
if (filter.has(toolName) && included.size === 0) {
continue;
}

View File

@@ -2,20 +2,90 @@ const {
SystemRoles,
Permissions,
PermissionTypes,
isMemoryEnabled,
removeNullishValues,
} = require('librechat-data-provider');
const { updateAccessPermissions } = require('~/models/Role');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const { isMemoryEnabled } = require('@librechat/api');
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
/**
* Updates role permissions intelligently - only updates permission types that:
* 1. Don't exist in the database (first time setup)
* 2. Are explicitly configured in the config file
* @param {object} params - The role name to update
* @param {string} params.roleName - The role name to update
* @param {object} params.allPermissions - All permissions to potentially update
* @param {object} params.interfaceConfig - The interface config from librechat.yaml
*/
async function updateRolePermissions({ roleName, allPermissions, interfaceConfig }) {
const existingRole = await getRoleByName(roleName);
const existingPermissions = existingRole?.permissions || {};
const permissionsToUpdate = {};
for (const [permType, perms] of Object.entries(allPermissions)) {
const permTypeExists = existingPermissions[permType];
const isExplicitlyConfigured = interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
// Only update if: doesn't exist OR explicitly configured
if (!permTypeExists || isExplicitlyConfigured) {
permissionsToUpdate[permType] = perms;
if (!permTypeExists) {
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
} else if (isExplicitlyConfigured) {
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
}
} else {
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
}
}
if (Object.keys(permissionsToUpdate).length > 0) {
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
}
}
/**
* Checks if a permission type has explicit configuration
*/
function hasExplicitConfig(interfaceConfig, permissionType) {
switch (permissionType) {
case PermissionTypes.PROMPTS:
return interfaceConfig.prompts !== undefined;
case PermissionTypes.BOOKMARKS:
return interfaceConfig.bookmarks !== undefined;
case PermissionTypes.MEMORIES:
return interfaceConfig.memories !== undefined;
case PermissionTypes.MULTI_CONVO:
return interfaceConfig.multiConvo !== undefined;
case PermissionTypes.AGENTS:
return interfaceConfig.agents !== undefined;
case PermissionTypes.TEMPORARY_CHAT:
return interfaceConfig.temporaryChat !== undefined;
case PermissionTypes.RUN_CODE:
return interfaceConfig.runCode !== undefined;
case PermissionTypes.WEB_SEARCH:
return interfaceConfig.webSearch !== undefined;
case PermissionTypes.PEOPLE_PICKER:
return interfaceConfig.peoplePicker !== undefined;
case PermissionTypes.MARKETPLACE:
return interfaceConfig.marketplace !== undefined;
case PermissionTypes.FILE_SEARCH:
return interfaceConfig.fileSearch !== undefined;
case PermissionTypes.FILE_CITATIONS:
return interfaceConfig.fileCitations !== undefined;
default:
return false;
}
}
/**
* Loads the default interface object.
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
* @param {SystemRoles} [roleName] - The role to load the default interface for, defaults to `'USER'`.
* @returns {Promise<TCustomConfig['interface']>} The default interface object.
*/
async function loadDefaultInterface(config, configDefaults, roleName = SystemRoles.USER) {
async function loadDefaultInterface(config, configDefaults) {
const { interface: interfaceConfig } = config ?? {};
const { interface: defaults } = configDefaults;
const hasModelSpecs = config?.modelSpecs?.list?.length > 0;
@@ -54,73 +124,44 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
peoplePicker: {
admin: {
users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users,
groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups,
roles: interfaceConfig?.peoplePicker?.admin?.roles ?? defaults.peoplePicker?.admin.roles,
},
user: {
users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users,
groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups,
roles: interfaceConfig?.peoplePicker?.user?.roles ?? defaults.peoplePicker?.user.roles,
},
users: interfaceConfig?.peoplePicker?.users ?? defaults.peoplePicker?.users,
groups: interfaceConfig?.peoplePicker?.groups ?? defaults.peoplePicker?.groups,
roles: interfaceConfig?.peoplePicker?.roles ?? defaults.peoplePicker?.roles,
},
marketplace: {
admin: {
use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use,
},
user: {
use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use,
},
use: interfaceConfig?.marketplace?.use ?? defaults.marketplace?.use,
},
});
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 },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups,
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker.user?.roles,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.user?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
});
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 },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups,
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker.admin?.roles,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.admin?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
});
for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) {
await updateRolePermissions({
roleName,
allPermissions: {
[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 },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker?.groups,
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker?.roles,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
},
interfaceConfig,
});
}
let i = 0;
const logSettings = () => {

View File

@@ -1,12 +1,19 @@
const { SystemRoles, Permissions, PermissionTypes } = require('librechat-data-provider');
const { updateAccessPermissions } = require('~/models/Role');
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
const { loadDefaultInterface } = require('./interface');
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),
getRoleByName: jest.fn(),
}));
describe('loadDefaultInterface', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock getRoleByName to return null (no existing permissions)
getRoleByName.mockResolvedValue(null);
});
it('should call updateAccessPermissions with the correct parameters when permission types are true', async () => {
const config = {
interface: {
@@ -20,13 +27,21 @@ describe('loadDefaultInterface', () => {
webSearch: true,
fileSearch: true,
fileCitations: true,
peoplePicker: {
users: true,
groups: true,
roles: true,
},
marketplace: {
use: true,
},
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
@@ -38,14 +53,31 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: true,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
};
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
// Check USER role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
null,
);
// Check ADMIN role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
null,
);
});
it('should call updateAccessPermissions with false when permission types are false', async () => {
@@ -61,13 +93,21 @@ describe('loadDefaultInterface', () => {
webSearch: false,
fileSearch: false,
fileCitations: false,
peoplePicker: {
users: false,
groups: false,
roles: false,
},
marketplace: {
use: false,
},
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
@@ -76,14 +116,31 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
});
};
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
// Check USER role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
null,
);
// Check ADMIN role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
null,
);
});
it('should call updateAccessPermissions with undefined when permission types are not specified in config', async () => {
@@ -92,7 +149,7 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
@@ -106,52 +163,29 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_ROLES]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with undefined when permission types are explicitly undefined', async () => {
const config = {
interface: {
prompts: undefined,
bookmarks: undefined,
memories: undefined,
multiConvo: undefined,
agents: undefined,
temporaryChat: undefined,
runCode: undefined,
webSearch: undefined,
fileSearch: undefined,
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
// Check USER role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
null,
);
// Check ADMIN role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
null,
);
});
it('should call updateAccessPermissions with mixed values for permission types', async () => {
@@ -173,7 +207,7 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
@@ -184,15 +218,103 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_ROLES]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
};
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
// Check USER role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
null,
);
// Check ADMIN role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
null,
);
});
it('should call updateAccessPermissions with true when config is undefined', async () => {
it('should use default values when config is undefined', async () => {
const config = undefined;
const configDefaults = {
interface: {
prompts: true,
bookmarks: true,
memories: true,
multiConvo: true,
agents: true,
temporaryChat: true,
runCode: true,
webSearch: true,
fileSearch: true,
fileCitations: true,
peoplePicker: {
users: true,
groups: true,
roles: true,
},
marketplace: {
use: false,
},
},
};
await loadDefaultInterface(config, configDefaults);
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: true,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
};
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
// Check USER role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
null,
);
// Check ADMIN role call
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
null,
);
});
it('should only update permissions that do not exist when no config provided', async () => {
// Mock that some permissions already exist
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
},
});
const config = undefined;
const configDefaults = {
interface: {
@@ -211,372 +333,110 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
// Should be called with all permissions EXCEPT prompts and agents (which already exist)
const expectedPermissions = {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_ROLES]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
};
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
expect.objectContaining({
permissions: {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
},
}),
);
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
expect.objectContaining({
permissions: {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
},
}),
);
});
it('should call updateAccessPermissions with the correct parameters when multiConvo is true', async () => {
const config = { interface: { multiConvo: true } };
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
it('should override existing permissions when explicitly configured', async () => {
// Mock that some permissions already exist
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with false when multiConvo is false', async () => {
const config = { interface: { multiConvo: false } };
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with undefined when multiConvo is not specified in config', async () => {
const config = {};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with all interface options including multiConvo', async () => {
const config = {
interface: {
prompts: true,
bookmarks: false,
memories: true,
multiConvo: true,
agents: false,
temporaryChat: true,
runCode: false,
fileSearch: true,
prompts: true, // Explicitly set, should override existing false
// agents not specified, so existing false should be preserved
// bookmarks not specified, so existing false should be preserved
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should use default values for multiConvo when config is undefined', async () => {
const config = undefined;
const configDefaults = {
interface: {
prompts: true,
prompts: false,
agents: true,
bookmarks: true,
memories: false,
multiConvo: false,
agents: undefined,
temporaryChat: undefined,
runCode: undefined,
webSearch: undefined,
fileSearch: true,
},
};
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
// Should update prompts (explicitly configured) and all other permissions that don't exist
const expectedPermissions = {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, // Explicitly configured
// All other permissions that don't exist in the database
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with the correct parameters when WEB_SEARCH is undefined', async () => {
const config = {
interface: {
prompts: true,
bookmarks: false,
memories: true,
multiConvo: true,
agents: false,
temporaryChat: true,
runCode: false,
fileCitations: true,
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
[Permissions.VIEW_ROLES]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
});
it('should call updateAccessPermissions with the correct parameters when FILE_SEARCH is true', async () => {
const config = {
interface: {
fileSearch: true,
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with false when FILE_SEARCH is false', async () => {
const config = {
interface: {
fileSearch: false,
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with all interface options including fileSearch', async () => {
const config = {
interface: {
prompts: true,
bookmarks: false,
memories: true,
multiConvo: true,
agents: false,
temporaryChat: true,
runCode: false,
webSearch: true,
fileSearch: true,
},
};
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with the correct parameters when fileCitations is true', async () => {
const config = { interface: { fileCitations: true } };
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
});
});
it('should call updateAccessPermissions with false when fileCitations is false', async () => {
const config = { interface: { fileCitations: false } };
const configDefaults = { interface: {} };
await loadDefaultInterface(config, configDefaults);
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
});
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.USER,
expectedPermissions,
expect.objectContaining({
permissions: expect.any(Object),
}),
);
expect(updateAccessPermissions).toHaveBeenCalledWith(
SystemRoles.ADMIN,
expectedPermissions,
expect.objectContaining({
permissions: expect.any(Object),
}),
);
});
});

View File

@@ -1,10 +1,10 @@
const fs = require('fs');
const { isEnabled } = require('@librechat/api');
const LdapStrategy = require('passport-ldapauth');
const { SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const { isEnabled } = require('~/server/utils');
const {
LDAP_URL,
@@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
if (user && user.provider !== 'ldap') {
logger.info(
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =

View File

@@ -3,9 +3,9 @@ const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
const jwtDecode = require('jsonwebtoken/decode');
const { CacheKeys } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { hashToken, logger } = require('@librechat/data-schemas');
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -320,6 +320,14 @@ async function setupOpenId() {
} for openidId: ${claims.sub}`,
);
}
if (user != null && user.provider !== 'openid') {
logger.info(
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),

View File

@@ -1,7 +1,8 @@
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const { setupOpenId } = require('./openidStrategy');
const { ErrorTypes } = require('librechat-data-provider');
const { findUser, createUser, updateUser } = require('~/models');
const { setupOpenId } = require('./openidStrategy');
// --- Mocks ---
jest.mock('node-fetch');
@@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
fetchUserInfo: jest.fn().mockImplementation(() => {
// Only return additional properties, but don't override any claims
return Promise.resolve({});
}),
@@ -261,17 +262,20 @@ describe('setupOpenId', () => {
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
// Arrange simulate that a user already exists with openid provider
const existingUser = {
_id: 'existingUserId',
provider: 'local',
provider: 'openid',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
if (
query.openidId === tokenset.claims().sub ||
(query.email === tokenset.claims().email && query.provider === 'openid')
) {
return existingUser;
}
return null;
@@ -294,12 +298,38 @@ describe('setupOpenId', () => {
);
});
it('should block login when email exists with different provider', async () => {
// Arrange simulate that a user exists with same email but different provider
const existingUser = {
_id: 'existingUserId',
provider: 'google',
email: tokenset.claims().email,
googleId: 'some-google-id',
username: 'existinguser',
name: 'Existing User',
};
findUser.mockImplementation(async (query) => {
if (query.email === tokenset.claims().email && !query.provider) {
return existingUser;
}
return null;
});
// Act
const result = await validate(tokenset);
// Assert verify that the strategy rejects login
expect(result.user).toBe(false);
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
expect(createUser).not.toHaveBeenCalled();
expect(updateUser).not.toHaveBeenCalled();
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
const userinfo = tokenset.claims();
// Act
const { user, details } = await validate(tokenset);
@@ -310,9 +340,6 @@ describe('setupOpenId', () => {
});
it('should attempt to download and save the avatar if picture is provided', async () => {
// Arrange ensure userinfo contains a picture URL
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);

View File

@@ -22,9 +22,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = false;
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
const hasManualFlag =
typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
updatedAvatar = avatarUrl;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
} else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
const userId = oldUser._id;
const resizedBuffer = await resizeAvatar({
userId,

View File

@@ -0,0 +1,164 @@
const { FileSources } = require('librechat-data-provider');
const { handleExistingUser } = require('./process');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/models', () => ({
updateUser: jest.fn(),
createUser: jest.fn(),
getUserById: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getBalanceConfig: jest.fn(),
}));
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser } = require('~/models');
describe('handleExistingUser', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.CDN_PROVIDER = FileSources.local;
});
it('should handle null avatar without throwing error', async () => {
const oldUser = {
_id: 'user123',
avatar: null,
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle undefined avatar without throwing error', async () => {
const oldUser = {
_id: 'user123',
// avatar is undefined
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should not update avatar if it has manual=true flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).not.toHaveBeenCalled();
});
it('should update avatar for local storage when avatar has no manual flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/old-avatar.png',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should process avatar for non-local storage', async () => {
process.env.CDN_PROVIDER = 's3';
const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
const oldUser = {
_id: 'user123',
avatar: null,
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(resizeAvatar).toHaveBeenCalledWith({
userId: 'user123',
input: avatarUrl,
});
expect(mockProcessAvatar).toHaveBeenCalledWith({
buffer: Buffer.from('resized-image'),
userId: 'user123',
manual: 'false',
});
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
});
it('should not update if avatar already has manual flag in non-local storage', async () => {
process.env.CDN_PROVIDER = 's3';
const oldUser = {
_id: 'user123',
avatar: 'https://cdn.example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(resizeAvatar).not.toHaveBeenCalled();
expect(updateUser).not.toHaveBeenCalled();
});
it('should handle avatar with query parameters but without manual flag', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?size=large&format=webp',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle empty string avatar', async () => {
const oldUser = {
_id: 'user123',
avatar: '',
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle avatar with manual=false parameter', async () => {
const oldUser = {
_id: 'user123',
avatar: 'https://example.com/avatar.png?manual=false',
};
const avatarUrl = 'https://example.com/new-avatar.png';
await handleExistingUser(oldUser, avatarUrl);
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
});
it('should handle oldUser being null gracefully', async () => {
const avatarUrl = 'https://example.com/avatar.png';
// This should throw an error when trying to access oldUser._id
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
});
});

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const passport = require('passport');
const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -203,6 +204,15 @@ async function setupSaml() {
);
}
if (user && user.provider !== 'saml') {
logger.info(
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullName = getFullName(profile);
const username = convertToUsername(

View File

@@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
});
it('should update an existing user on login', async () => {
// Set up findUser to return an existing user
// Set up findUser to return an existing user with saml provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'local',
provider: 'saml',
email: baseProfile.email,
samlId: '',
username: 'oldusername',
@@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
expect(user.email).toBe(baseProfile.email);
});
it('should block login when email exists with different provider', async () => {
// Set up findUser to return a user with different provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'google',
email: baseProfile.email,
googleId: 'some-google-id',
username: 'existinguser',
name: 'Existing User',
};
findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const result = await validate(profile);
expect(result.user).toBe(false);
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile = { ...baseProfile };

View File

@@ -1,6 +1,7 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const { findUser } = require('~/models');
const socialLogin =
@@ -11,12 +12,20 @@ const socialLogin =
profile,
});
const oldUser = await findUser({ email: email.trim() });
const existingUser = await findUser({ email: email.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (oldUser) {
await handleExistingUser(oldUser, avatarUrl);
return cb(null, oldUser);
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl);
return cb(null, existingUser);
} else if (existingUser) {
logger.info(
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.provider = existingUser.provider;
return cb(error);
}
if (ALLOW_SOCIAL_REGISTRATION) {

View File

@@ -888,6 +888,12 @@
* @memberof typedefs
*/
/**
* @exports IRole
* @typedef {import('@librechat/data-schemas').IRole} IRole
* @memberof typedefs
*/
/**
* @exports ObjectId
* @typedef {import('mongoose').Types.ObjectId} ObjectId

View File

@@ -19,6 +19,9 @@ const openAIModels = {
'gpt-4.1': 1047576,
'gpt-4.1-mini': 1047576,
'gpt-4.1-nano': 1047576,
'gpt-5': 400000,
'gpt-5-mini': 400000,
'gpt-5-nano': 400000,
'gpt-4o': 127500, // -500 from max
'gpt-4o-mini': 127500, // -500 from max
'gpt-4o-2024-05-13': 127500, // -500 from max
@@ -234,6 +237,9 @@ const aggregateModels = {
...xAIModels,
// misc.
kimi: 131000,
// GPT-OSS
'gpt-oss-20b': 131000,
'gpt-oss-120b': 131000,
};
const maxTokensMap = {
@@ -250,6 +256,11 @@ const modelMaxOutputs = {
o1: 32268, // -500 from max: 32,768
'o1-mini': 65136, // -500 from max: 65,536
'o1-preview': 32268, // -500 from max: 32,768
'gpt-5': 128000,
'gpt-5-mini': 128000,
'gpt-5-nano': 128000,
'gpt-oss-20b': 131000,
'gpt-oss-120b': 131000,
system_default: 1024,
};
@@ -468,10 +479,11 @@ const tiktokenModels = new Set([
]);
module.exports = {
tiktokenModels,
maxTokensMap,
inputSchema,
modelSchema,
maxTokensMap,
tiktokenModels,
maxOutputTokensMap,
matchModelName,
processModelData,
getModelMaxTokens,

View File

@@ -1,5 +1,11 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { getModelMaxTokens, processModelData, matchModelName, maxTokensMap } = require('./tokens');
const {
maxOutputTokensMap,
getModelMaxTokens,
processModelData,
matchModelName,
maxTokensMap,
} = require('./tokens');
describe('getModelMaxTokens', () => {
test('should return correct tokens for exact match', () => {
@@ -150,6 +156,35 @@ describe('getModelMaxTokens', () => {
);
});
test('should return correct tokens for gpt-5 matches', () => {
expect(getModelMaxTokens('gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('gpt-5-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('openai/gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
expect(getModelMaxTokens('gpt-5-2025-01-30')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5'],
);
});
test('should return correct tokens for gpt-5-mini matches', () => {
expect(getModelMaxTokens('gpt-5-mini')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini']);
expect(getModelMaxTokens('gpt-5-mini-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
);
expect(getModelMaxTokens('openai/gpt-5-mini')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
);
});
test('should return correct tokens for gpt-5-nano matches', () => {
expect(getModelMaxTokens('gpt-5-nano')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano']);
expect(getModelMaxTokens('gpt-5-nano-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
);
expect(getModelMaxTokens('openai/gpt-5-nano')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
);
});
test('should return correct tokens for Anthropic models', () => {
const models = [
'claude-2.1',
@@ -349,6 +384,39 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('o3')).toBe(o3Tokens);
expect(getModelMaxTokens('openai/o3')).toBe(o3Tokens);
});
test('should return correct tokens for GPT-OSS models', () => {
const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b'];
['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => {
expect(getModelMaxTokens(name)).toBe(expected);
});
});
test('should return correct max output tokens for GPT-5 models', () => {
const { getModelMaxOutputTokens } = require('./tokens');
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
);
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
);
});
});
test('should return correct max output tokens for GPT-OSS models', () => {
const { getModelMaxOutputTokens } = require('./tokens');
['gpt-oss-20b', 'gpt-oss-120b'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
);
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
);
});
});
});
describe('matchModelName', () => {
@@ -420,6 +488,25 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-4.1-nano-2024-08-06')).toBe('gpt-4.1-nano');
});
it('should return the closest matching key for gpt-5 matches', () => {
expect(matchModelName('openai/gpt-5')).toBe('gpt-5');
expect(matchModelName('gpt-5-preview')).toBe('gpt-5');
expect(matchModelName('gpt-5-2025-01-30')).toBe('gpt-5');
expect(matchModelName('gpt-5-2025-01-30-0130')).toBe('gpt-5');
});
it('should return the closest matching key for gpt-5-mini matches', () => {
expect(matchModelName('openai/gpt-5-mini')).toBe('gpt-5-mini');
expect(matchModelName('gpt-5-mini-preview')).toBe('gpt-5-mini');
expect(matchModelName('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
});
it('should return the closest matching key for gpt-5-nano matches', () => {
expect(matchModelName('openai/gpt-5-nano')).toBe('gpt-5-nano');
expect(matchModelName('gpt-5-nano-preview')).toBe('gpt-5-nano');
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
});
// Tests for Google models
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc1",
"version": "v0.8.0-rc2",
"description": "",
"type": "module",
"scripts": {

View File

@@ -0,0 +1,46 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils';
interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
conversationId: string | null;
}
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => {
return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null,
content: latestMessage?.content ?? null,
} as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => ({
isSubmitting,
latestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
);
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
}
export function useArtifactsContext() {
const context = useContext(ArtifactsContext);
if (!context) {
throw new Error('useArtifactsContext must be used within ArtifactsProvider');
}
return context;
}

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useGetAllPromptGroups } from '~/data-provider';
import { usePromptGroupsNav } from '~/hooks';
import { mapPromptGroups } from '~/utils';
type AllPromptGroupsData =
| {
promptsMap: Record<string, TPromptGroup>;
promptGroups: PromptOption[];
}
| undefined;
type PromptGroupsContextType =
| (ReturnType<typeof usePromptGroupsNav> & {
allPromptGroups: {
data: AllPromptGroupsData;
isLoading: boolean;
};
})
| null;
const PromptGroupsContext = createContext<PromptGroupsContextType>(null);
export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
const promptGroupsNav = usePromptGroupsNav();
const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, {
select: (data) => {
const mappedArray: PromptOption[] = data.map((group) => ({
id: group._id ?? '',
type: 'prompt',
value: group.command ?? group.name,
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.name
}: ${
(group.oneliner?.length ?? 0) > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
}));
const promptsMap = mapPromptGroups(data);
return {
promptsMap,
promptGroups: mappedArray,
};
},
});
const contextValue = useMemo(
() => ({
...promptGroupsNav,
allPromptGroups: {
data: allGroupsData,
isLoading: isLoadingAll,
},
}),
[promptGroupsNav, allGroupsData, isLoadingAll],
);
return (
<PromptGroupsContext.Provider value={contextValue}>{children}</PromptGroupsContext.Provider>
);
};
export const usePromptGroupsContext = () => {
const context = useContext(PromptGroupsContext);
if (!context) {
throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider');
}
return context;
};

View File

@@ -23,4 +23,6 @@ export * from './SetConvoContext';
export * from './SearchContext';
export * from './BadgeRowContext';
export * from './SidePanelContext';
export * from './ArtifactsContext';
export * from './PromptGroupsContext';
export { default as BadgeRowProvider } from './BadgeRowContext';

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Label } from '@librechat/client';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import { useLocalize } from '~/hooks';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
@@ -18,10 +19,10 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
return (
<div
className={cn(
'group relative flex overflow-hidden rounded-2xl',
'cursor-pointer transition-colors duration-200',
'aspect-[5/2.5] w-full',
'bg-surface-tertiary hover:bg-surface-hover-alt',
'group relative h-40 overflow-hidden rounded-xl border border-border-light',
'cursor-pointer shadow-sm transition-all duration-200 hover:border-border-medium hover:shadow-lg',
'bg-surface-tertiary hover:bg-surface-hover',
'space-y-3 p-4',
className,
)}
onClick={onClick}
@@ -39,50 +40,57 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
}
}}
>
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
{/* Agent avatar section - left side, responsive */}
<div className="flex flex-shrink-0 items-center">
{renderAgentAvatar(agent, { size: 'md' })}
{/* Two column layout */}
<div className="flex h-full items-start gap-3">
{/* Left column: Avatar and Category */}
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
{/* Category tag */}
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
{agent.category && (
<Label className="line-clamp-1 font-normal">
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
</Label>
)}
</div>
</div>
{/* Agent info section - right side, responsive */}
<div className="flex min-w-0 flex-1 flex-col justify-center">
{/* Agent name - responsive text sizing */}
<h3 className="mb-1 line-clamp-1 text-base font-bold text-text-primary sm:mb-2 sm:text-lg">
{agent.name}
</h3>
{/* Right column: Name, description, and other content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center justify-between">
{/* Agent name */}
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
{agent.name}
</Label>
{/* Agent description - responsive text sizing and spacing */}
{/* Owner info */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex items-center text-sm text-text-secondary">
<Label className="mr-1">🔹</Label>
<Label>{displayName}</Label>
</div>
);
}
return null;
})()}
</div>
{/* Agent description */}
<p
id={`agent-${agent.id}-description`}
className={cn(
'mb-1 line-clamp-2 text-xs leading-relaxed text-text-secondary',
'sm:mb-2 sm:text-sm',
)}
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
>
{agent.description || (
<span className="italic text-text-secondary">
<Label className="font-normal italic text-text-primary">
{localize('com_agents_no_description')}
</span>
</Label>
)}
</p>
{/* Owner info - responsive text sizing */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex items-center text-xs text-text-tertiary sm:text-sm">
<span className="font-light">{localize('com_agents_created_by')}</span>
<span className="ml-1 font-bold">{displayName}</span>
</div>
);
}
return null;
})()}
</div>
</div>
</div>

View File

@@ -1,24 +1,30 @@
import React, { useMemo } from 'react';
import { Button, Spinner } from '@librechat/client';
import React, { useMemo, useEffect } from 'react';
import { Spinner } from '@librechat/client';
import { PermissionBits } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories, useLocalize } from '~/hooks';
import { useInfiniteScroll } from '~/hooks/useInfiniteScroll';
import { useHasData } from './SmartLoader';
import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard';
import { cn } from '~/utils';
interface AgentGridProps {
category: string; // Currently selected category
searchQuery: string; // Current search query
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
}
/**
* Component for displaying a grid of agent cards
*/
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const AgentGrid: React.FC<AgentGridProps> = ({
category,
searchQuery,
onSelectAgent,
scrollElement,
}) => {
const localize = useLocalize();
// Get category data from API
@@ -78,6 +84,26 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
// Check if we have meaningful data to prevent unnecessary loading states
const hasData = useHasData(data?.pages?.[0]);
// Set up infinite scroll
const { setScrollElement } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
fetchNextPage: () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
},
threshold: 0.8, // Trigger when 80% scrolled
throttleMs: 200,
});
// Connect the scroll element when it's provided
useEffect(() => {
if (scrollElement) {
setScrollElement(scrollElement);
}
}, [scrollElement, setScrollElement]);
/**
* Get category display name from API data or use fallback
*/
@@ -99,59 +125,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
};
/**
* Load more agents when "See More" button is clicked
*/
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
/**
* Get the appropriate title for the agents grid based on current state
*/
const getGridTitle = () => {
if (searchQuery) {
return localize('com_agents_results_for', { query: searchQuery });
}
return getCategoryDisplayName(category);
};
// Loading skeleton component
const loadingSkeleton = (
<div className="space-y-6">
<div className="mb-4">
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-surface-tertiary"></div>
<div className="h-4 w-64 animate-pulse rounded-md bg-surface-tertiary"></div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{Array(6)
.fill(0)
.map((_, index) => (
<div
key={index}
className={cn(
'flex animate-pulse overflow-hidden rounded-2xl',
'aspect-[5/2.5] w-full',
'bg-surface-tertiary',
)}
>
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
{/* Avatar skeleton */}
<div className="flex flex-shrink-0 items-center">
<div className="h-10 w-10 rounded-full bg-surface-secondary sm:h-12 sm:w-12"></div>
</div>
{/* Content skeleton */}
<div className="flex flex-1 flex-col justify-center space-y-2">
<div className="h-4 w-3/4 rounded bg-surface-secondary"></div>
<div className="h-3 w-full rounded bg-surface-secondary"></div>
</div>
</div>
</div>
))}
</div>
// Simple loading spinner
const loadingSpinner = (
<div className="flex justify-center py-12">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
@@ -179,19 +156,6 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
aria-live="polite"
aria-busy={isLoading && !hasData}
>
{/* Grid title - only show for search results */}
{searchQuery && (
<div className="mb-4">
<h2
className="text-xl font-bold text-text-primary"
id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
>
{getGridTitle()}
</h2>
</div>
)}
{/* Handle empty results with enhanced accessibility */}
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
<div
@@ -204,16 +168,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
: localize('com_agents_empty_state_heading')
}
>
<h3 className="mb-2 text-lg font-medium">
{searchQuery
? localize('com_agents_search_empty_heading')
: localize('com_agents_empty_state_heading')}
</h3>
<p className="text-sm">
{searchQuery
? localize('com_agents_no_results')
: localize('com_agents_none_in_category')}
</p>
<h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3>
</div>
) : (
<>
@@ -244,9 +199,9 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
)}
{/* Loading indicator when fetching more with accessibility */}
{isFetching && hasNextPage && (
{isFetchingNextPage && (
<div
className="flex justify-center py-4"
className="flex justify-center py-8"
role="status"
aria-live="polite"
aria-label={localize('com_agents_loading')}
@@ -256,23 +211,12 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
</div>
)}
{/* Load more button with enhanced accessibility */}
{hasNextPage && !isFetching && (
<div className="mt-8 flex justify-center">
<Button
variant="outline"
onClick={handleLoadMore}
className={cn(
'min-w-[160px] border-2 border-border-medium bg-surface-primary px-6 py-3 font-medium text-text-primary',
'shadow-sm transition-all duration-200 hover:border-border-heavy hover:bg-surface-hover',
'hover:shadow-md focus:ring-2 focus:ring-ring focus:ring-offset-2',
)}
aria-label={localize('com_agents_load_more_label', {
category: getCategoryDisplayName(category),
})}
>
{localize('com_agents_see_more')}
</Button>
{/* End of results indicator */}
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
<div className="mt-8 text-center">
<p className="text-sm text-text-secondary">
{localize('com_agents_no_more_results')}
</p>
</div>
)}
</>
@@ -281,7 +225,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
);
if (isLoading || (isFetching && !isFetchingNextPage)) {
return loadingSkeleton;
return loadingSpinner;
}
return mainContent;
};

View File

@@ -24,6 +24,7 @@ interface CategoryTabsProps {
* Renders a tabbed navigation interface showing agent categories.
* Includes loading states, empty state handling, and displays counts for each category.
* Uses database-driven category labels with no hardcoded values.
* Features multi-row wrapping for better responsive behavior.
*/
const CategoryTabs: React.FC<CategoryTabsProps> = ({
categories,
@@ -46,14 +47,13 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
};
// Loading skeleton component
const loadingSkeleton = (
<div className="w-full pb-2">
<div className="no-scrollbar flex gap-1.5 overflow-x-auto px-4">
<div className="flex flex-wrap justify-center gap-1.5 px-4">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="h-[36px] min-w-[80px] animate-pulse rounded-md bg-surface-tertiary"
className="h-[36px] min-w-[80px] animate-pulse rounded-lg bg-surface-tertiary"
/>
))}
</div>
@@ -67,15 +67,23 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
break;
case 'ArrowUp':
e.preventDefault();
// Move up a row (approximate by moving back ~4-6 items)
newIndex = Math.max(0, currentIndex - 5);
break;
case 'ArrowDown':
e.preventDefault();
// Move down a row (approximate by moving forward ~4-6 items)
newIndex = Math.min(categories.length - 1, currentIndex + 5);
break;
case 'Home':
e.preventDefault();
newIndex = 0;
@@ -94,7 +102,9 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
// Focus the new tab
setTimeout(() => {
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
newTab?.focus();
if (newTab) {
newTab.focus();
}
}, 0);
}
};
@@ -108,16 +118,12 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
// Main tabs content
const tabsContent = (
<div className="relative w-full pb-2">
<div className="w-full pb-2">
<div
className="no-scrollbar flex gap-1.5 overflow-x-auto overscroll-x-contain px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="flex flex-wrap justify-center gap-1.5 px-4"
role="tablist"
aria-label={localize('com_agents_category_tabs_label')}
aria-orientation="horizontal"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
}}
>
{categories.map((category, index) => (
<button
@@ -126,14 +132,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
onClick={() => onChange(category.value)}
onKeyDown={(e) => handleKeyDown(e, category.value)}
className={cn(
'relative mt-1 cursor-pointer select-none whitespace-nowrap rounded-md px-3 py-2',
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
activeTab === category.value
? 'bg-surface-tertiary text-text-primary'
: 'bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
? 'rounded-t-lg bg-surface-hover text-text-primary'
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
)}
style={{
scrollSnapAlign: 'start',
}}
role="tab"
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
@@ -9,6 +9,7 @@ import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
import { SidePanelGroup } from '~/components/SidePanel';
@@ -43,11 +44,24 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
// Get URL parameters (default to 'promoted' instead of 'all')
const activeTab = category || 'promoted';
// Get URL parameters (default to 'all' to ensure users see agents)
const activeTab = category || 'all';
const searchQuery = searchParams.get('q') || '';
const selectedAgentId = searchParams.get('agent_id') || '';
// Animation state
type Direction = 'left' | 'right';
const [displayCategory, setDisplayCategory] = useState<string>(activeTab);
const [nextCategory, setNextCategory] = useState<string | null>(null);
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
// Keep a ref of initial mount to avoid animating first sync
const didInitRef = useRef(false);
// Ref for the scrollable container to enable infinite scroll
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Local state
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
@@ -64,6 +78,15 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
localStorage.setItem('fullPanelCollapse', 'false');
}, [setHideSidePanel, hideSidePanel]);
// Redirect base /agents route to /agents/all for consistency
useEffect(() => {
if (!category && window.location.pathname === '/agents') {
const currentSearchParams = searchParams.toString();
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
navigate(`/agents/all${searchParamsStr}`, { replace: true });
}
}, [category, navigate, searchParams]);
// Ensure endpoints config is loaded first (required for agent queries)
useGetEndpointsQuery();
@@ -101,22 +124,92 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
};
/**
* Handle category tab selection changes
*
* @param tabValue - The selected category value
* Determine ordered tabs to compute indices for direction
*/
const orderedTabs = useMemo<string[]>(() => {
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
// Ensure unique and stable order - 'all' should be last to match server response
const set = new Set<string>(['promoted', ...dynamic, 'all']);
return Array.from(set);
}, [categoriesQuery.data]);
const getTabIndex = useCallback(
(tab: string): number => {
const idx = orderedTabs.indexOf(tab);
return idx >= 0 ? idx : 0;
},
[orderedTabs],
);
/**
* Handle category tab selection changes with directional animation
*/
const handleTabChange = (tabValue: string) => {
if (tabValue === activeTab || isTransitioning) {
// Ignore redundant or rapid clicks during transition
return;
}
const currentIndex = getTabIndex(displayCategory);
const newIndex = getTabIndex(tabValue);
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
setAnimationDirection(direction);
setNextCategory(tabValue);
setIsTransitioning(true);
// Update URL immediately, preserving current search params
const currentSearchParams = searchParams.toString();
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
// Navigate to the selected category
if (tabValue === 'promoted') {
navigate(`/agents${searchParamsStr}`);
} else {
navigate(`/agents/${tabValue}${searchParamsStr}`);
}
// Complete transition after 300ms
window.setTimeout(() => {
setDisplayCategory(tabValue);
setNextCategory(null);
setIsTransitioning(false);
}, 300);
};
/**
* Sync animation when URL changes externally (back/forward or deep links)
*/
useEffect(() => {
if (!didInitRef.current) {
// First render: do not animate; just set display to current active tab
didInitRef.current = true;
setDisplayCategory(activeTab);
return;
}
if (isTransitioning || activeTab === displayCategory) {
return;
}
// Compute direction vs current displayCategory and animate
const currentIndex = getTabIndex(displayCategory);
const newIndex = getTabIndex(activeTab);
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
setAnimationDirection(direction);
setNextCategory(activeTab);
setIsTransitioning(true);
const timeoutId = window.setTimeout(() => {
setDisplayCategory(activeTab);
setNextCategory(null);
setIsTransitioning(false);
}, 300);
return () => {
window.clearTimeout(timeoutId);
};
}, [activeTab, displayCategory, isTransitioning, getTabIndex]);
// No longer needed with keyframes
/**
* Handle search query changes
*
@@ -207,7 +300,14 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
>
<main className="flex h-full flex-col overflow-hidden" role="main">
{/* Scrollable container */}
<div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
<div
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Admin Settings */}
<div className="absolute right-4 top-4 z-30">
<MarketplaceAdminSettings />
</div>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
@@ -276,63 +376,158 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (activeTab === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
{/* Two-pane animated container wrapping category header + grid */}
<div className="relative overflow-hidden">
{/* Current content pane */}
<div
className={cn(
isTransitioning &&
(animationDirection === 'right'
? 'motion-safe:animate-slide-out-left'
: 'motion-safe:animate-slide-out-right'),
)}
key={`pane-current-${displayCategory}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (displayCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (displayCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
displayCategory.charAt(0).toUpperCase() +
displayCategory.slice(1),
description: '',
};
};
}
if (activeTab === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === activeTab,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
const { name, description } = getCategoryData();
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: '',
};
};
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
{/* Agent grid */}
<AgentGrid
key={`grid-${displayCategory}`}
category={displayCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
/>
</div>
)}
{/* Agent grid */}
<AgentGrid
category={activeTab}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
/>
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div
className={cn(
'absolute inset-0',
animationDirection === 'right'
? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-in-left',
)}
key={`pane-next-${nextCategory}-${animationDirection}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (nextCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (nextCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === nextCategory,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
(nextCategory || '').charAt(0).toUpperCase() +
(nextCategory || '').slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
key={`grid-${nextCategory}`}
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
/>
</div>
)}
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
</div>
{/* Agent detail dialog */}

View File

@@ -0,0 +1,211 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
DropdownPopup,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
type FormValues = {
[Permissions.USE]: boolean;
};
type LabelControllerProps = {
label: string;
marketplacePerm: Permissions.USE;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
marketplacePerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(marketplacePerm, !getValues(marketplacePerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={marketplacePerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
/>
)}
/>
</div>
);
const MarketplaceAdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
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>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.MARKETPLACE];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData: {
marketplacePerm: Permissions.USE;
label: string;
}[] = [
{
marketplacePerm: Permissions.USE,
label: localize('com_ui_marketplace_allow_use'),
},
];
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 (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant={'outline'}
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_marketplace',
)}`}</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ marketplacePerm, label }) => (
<div key={marketplacePerm}>
<LabelController
control={control}
marketplacePerm={marketplacePerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
);
};
export default MarketplaceAdminSettings;

View File

@@ -73,33 +73,33 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
value={searchTerm}
onChange={handleChange}
placeholder={localize('com_agents_search_placeholder')}
className="h-14 rounded-2xl border-2 border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-lg placeholder:text-text-tertiary focus:border-border-heavy focus:ring-0"
className="h-14 rounded-2xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
aria-label={localize('com_agents_search_aria')}
aria-describedby="search-instructions search-results-count"
autoComplete="off"
spellCheck="false"
/>
{/* Search icon with proper accessibility */}
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
<Search className="h-6 w-6 text-text-tertiary" />
<Search className="size-5 text-text-secondary" />
</div>
{/* Hidden instructions for screen readers */}
<div id="search-instructions" className="sr-only">
{localize('com_agents_search_instructions')}
</div>
{/* Show clear button only when search has value - Google style */}
{searchTerm && (
<button
type="button"
onClick={handleClear}
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-text-tertiary transition-colors duration-150 hover:bg-text-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
className="group absolute right-4 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={localize('com_agents_clear_search')}
title={localize('com_agents_clear_search')}
>
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
<X
className="size-5 text-text-secondary transition-colors duration-200 group-hover:text-text-primary"
strokeWidth={2.5}
/>
</button>
)}
</div>

View File

@@ -0,0 +1,348 @@
import React, { useMemo, useEffect, useCallback, useRef } from 'react';
import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized';
import { throttle } from 'lodash';
import { Spinner } from '@librechat/client';
import { PermissionBits } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories, useLocalize } from '~/hooks';
import { useHasData } from './SmartLoader';
import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard';
import { cn } from '~/utils';
interface VirtualizedAgentGridProps {
category: string;
searchQuery: string;
onSelectAgent: (agent: t.Agent) => void;
scrollElement?: HTMLElement | null;
}
// Constants for layout calculations
const CARD_HEIGHT = 160; // h-40 in pixels
const GAP_SIZE = 24; // gap-6 in pixels
const ROW_HEIGHT = CARD_HEIGHT + GAP_SIZE;
const CARDS_PER_ROW_MOBILE = 1;
const CARDS_PER_ROW_DESKTOP = 2;
const OVERSCAN_ROW_COUNT = 3;
/**
* Virtualized grid component for displaying agent cards with high performance
*/
const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({
category,
searchQuery,
onSelectAgent,
scrollElement,
}) => {
const localize = useLocalize();
const listRef = useRef<VirtualList>(null);
const { categories } = useAgentCategories();
// Build query parameters
const queryParams = useMemo(() => {
const params: {
requiredPermission: number;
category?: string;
search?: string;
limit: number;
promoted?: 0 | 1;
} = {
requiredPermission: PermissionBits.VIEW,
// Align with AgentGrid to eliminate API mismatch as a factor
limit: 6,
};
if (searchQuery) {
params.search = searchQuery;
if (category !== 'all' && category !== 'promoted') {
params.category = category;
}
} else {
if (category === 'promoted') {
params.promoted = 1;
} else if (category !== 'all') {
params.category = category;
}
}
return params;
}, [category, searchQuery]);
// Use infinite query
const {
data,
isLoading,
error,
isFetching,
fetchNextPage,
hasNextPage,
refetch,
isFetchingNextPage,
} = useMarketplaceAgentsInfiniteQuery(queryParams);
// Flatten pages into single array
const currentAgents = useMemo(() => {
if (!data?.pages) return [];
return data.pages.flatMap((page) => page.data || []);
}, [data?.pages]);
const hasData = useHasData(data?.pages?.[0]);
// Direct scroll handling for virtualized component to avoid hook conflicts
useEffect(() => {
if (!scrollElement) return;
const throttledScrollHandler = throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
if (scrollPosition >= 0.8 && hasNextPage && !isFetchingNextPage && !isFetching) {
fetchNextPage();
}
}, 200);
scrollElement.addEventListener('scroll', throttledScrollHandler, { passive: true });
return () => {
scrollElement.removeEventListener('scroll', throttledScrollHandler);
throttledScrollHandler.cancel?.();
};
}, [scrollElement, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage, category]);
// Separate effect for list re-rendering on data changes
useEffect(() => {
if (listRef.current) {
listRef.current.forceUpdateGrid();
}
}, [currentAgents]);
// Helper functions for grid calculations
const getCardsPerRow = useCallback((width: number) => {
return width >= 768 ? CARDS_PER_ROW_DESKTOP : CARDS_PER_ROW_MOBILE;
}, []);
const getRowCount = useCallback((agentCount: number, cardsPerRow: number) => {
return Math.ceil(agentCount / cardsPerRow);
}, []);
const getRowItems = useCallback(
(rowIndex: number, cardsPerRow: number) => {
const startIndex = rowIndex * cardsPerRow;
const endIndex = Math.min(startIndex + cardsPerRow, currentAgents.length);
return currentAgents.slice(startIndex, endIndex);
},
[currentAgents],
);
const getCategoryDisplayName = (categoryValue: string) => {
const categoryData = categories.find((cat) => cat.value === categoryValue);
if (categoryData) {
return categoryData.label;
}
if (categoryValue === 'promoted') {
return localize('com_agents_top_picks');
}
if (categoryValue === 'all') {
return 'All';
}
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
};
// Row renderer for virtual list
const rowRenderer = useCallback(
({ index, key, style, parent }: any) => {
const containerWidth = parent?.props?.width || 800;
const cardsPerRow = getCardsPerRow(containerWidth);
const rowAgents = getRowItems(index, cardsPerRow);
const totalRows = getRowCount(currentAgents.length, cardsPerRow);
const isLastRow = index === totalRows - 1;
const showLoading = isFetchingNextPage && isLastRow;
return (
<div key={key} style={style}>
<div
className={cn(
'grid gap-6 px-0',
cardsPerRow === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2',
)}
role="row"
aria-rowindex={index + 1}
>
{rowAgents.map((agent: t.Agent, cardIndex: number) => {
const globalIndex = index * cardsPerRow + cardIndex;
return (
<div key={`${agent.id}-${globalIndex}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
</div>
);
})}
</div>
{showLoading && (
<div
className="flex justify-center py-4"
role="status"
aria-live="polite"
aria-label={localize('com_agents_loading')}
>
<Spinner className="h-6 w-6 text-primary" />
<span className="sr-only">{localize('com_agents_loading')}</span>
</div>
)}
</div>
);
},
[
currentAgents,
getCardsPerRow,
getRowItems,
getRowCount,
isFetchingNextPage,
localize,
onSelectAgent,
],
);
// Simple loading spinner
const loadingSpinner = (
<div className="flex justify-center py-12">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
// Handle error state
if (error) {
return (
<ErrorDisplay
error={error || 'Unknown error occurred'}
onRetry={() => refetch()}
context={{ searchQuery, category }}
/>
);
}
// Handle loading state
if (isLoading || (isFetching && !isFetchingNextPage)) {
return loadingSpinner;
}
// Handle empty results
if ((!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching) {
return (
<div
className="py-12 text-center text-text-secondary"
role="status"
aria-live="polite"
aria-label={
searchQuery
? localize('com_agents_search_empty_heading')
: localize('com_agents_empty_state_heading')
}
>
<h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3>
</div>
);
}
// Main virtualized content
return (
<div
className="space-y-6"
role="tabpanel"
id={`category-panel-${category}`}
aria-labelledby={`category-tab-${category}`}
aria-live="polite"
aria-busy={isLoading && !hasData}
>
{/* Screen reader announcement */}
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
{localize('com_agents_grid_announcement', {
count: currentAgents?.length || 0,
category: getCategoryDisplayName(category),
})}
</div>
{/* Virtualized grid with external scroll integration */}
<div
role="grid"
aria-label={localize('com_agents_grid_announcement', {
count: currentAgents.length,
category: getCategoryDisplayName(category),
})}
>
{scrollElement ? (
<WindowScroller scrollElement={scrollElement}>
{({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => (
<AutoSizer disableHeight>
{({ width }) => {
const cardsPerRow = getCardsPerRow(width);
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
return (
<div ref={registerChild}>
<VirtualList
ref={listRef}
autoHeight
height={height}
isScrolling={isScrolling}
onScroll={onChildScroll}
overscanRowCount={OVERSCAN_ROW_COUNT}
rowCount={rowCount}
rowHeight={ROW_HEIGHT}
rowRenderer={rowRenderer}
scrollTop={scrollTop}
width={width}
style={{ outline: 'none' }}
aria-rowcount={rowCount}
data-testid="virtual-list"
data-total-rows={rowCount}
/>
</div>
);
}}
</AutoSizer>
)}
</WindowScroller>
) : (
// Fallback for when no external scroll element is provided
<div style={{ height: 600 }}>
<AutoSizer>
{({ width, height }) => {
const cardsPerRow = getCardsPerRow(width);
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
return (
<VirtualList
ref={listRef}
height={height}
overscanRowCount={OVERSCAN_ROW_COUNT}
rowCount={rowCount}
rowHeight={ROW_HEIGHT}
rowRenderer={rowRenderer}
width={width}
style={{ outline: 'none' }}
aria-rowcount={rowCount}
data-testid="virtual-list"
data-total-rows={rowCount}
/>
);
}}
</AutoSizer>
</div>
)}
</div>
{/* End of results indicator */}
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
<div className="mt-8 text-center">
<p className="text-sm text-text-secondary">{localize('com_agents_no_more_results')}</p>
</div>
)}
</div>
);
};
export default VirtualizedAgentGrid;

View File

@@ -48,7 +48,7 @@ describe('AgentCard', () => {
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('🔹')).toBeInTheDocument();
expect(screen.getByText('Test Support')).toBeInTheDocument();
});
@@ -152,7 +152,7 @@ describe('AgentCard', () => {
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('🔹')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
@@ -165,7 +165,7 @@ describe('AgentCard', () => {
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('🔹')).toBeInTheDocument();
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
});
@@ -178,7 +178,7 @@ describe('AgentCard', () => {
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('🔹')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
@@ -195,7 +195,7 @@ describe('AgentCard', () => {
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('🔹')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
});

View File

@@ -33,13 +33,11 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
com_agents_see_more: 'See more',
com_agents_error_loading: 'Error loading agents',
com_agents_error_searching: 'Error searching agents',
com_agents_no_results: 'No agents found. Try another search term.',
com_agents_none_in_category: 'No agents found in this category',
com_agents_search_empty_heading: 'No results found',
com_agents_empty_state_heading: 'No agents available',
com_agents_loading: 'Loading...',
com_agents_grid_announcement: '{{count}} agents in {{category}}',
com_agents_load_more_label: 'Load more agents from {{category}}',
com_agents_no_more_results: "You've reached the end of the results",
};
let translation = mockTranslations[key] || key;
@@ -250,8 +248,9 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
</Wrapper>,
);
// Should show skeleton loading state
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
// Should show loading spinner
const spinner = document.querySelector('.text-primary');
expect(spinner).toBeInTheDocument();
});
it('should show empty state when no agents are available', () => {
@@ -312,7 +311,8 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
</Wrapper>,
);
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
// The component doesn't show search result titles, just displays the filtered agents
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
});
it('should show empty search results message', () => {
@@ -338,29 +338,16 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
</Wrapper>,
);
expect(screen.getByText('No results found')).toBeInTheDocument();
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
expect(screen.getByText('No agents available')).toBeInTheDocument();
});
});
describe('Load More Functionality', () => {
it('should show "See more" button when hasNextPage is true', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(
screen.getByRole('button', { name: 'Load more agents from Finance' }),
).toBeInTheDocument();
});
it('should not show "See more" button when hasNextPage is false', () => {
describe('Infinite Scroll Functionality', () => {
it('should show loading indicator when fetching next page', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
hasNextPage: false,
isFetchingNextPage: true,
hasNextPage: true,
});
const Wrapper = createWrapper();
@@ -370,7 +357,44 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
</Wrapper>,
);
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
expect(screen.getByText('Loading...')).toHaveClass('sr-only');
});
it('should show end of results message when hasNextPage is false and agents exist', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
hasNextPage: false,
isFetchingNextPage: false,
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByText("You've reached the end of the results")).toBeInTheDocument();
});
it('should not show end of results message when no agents exist', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
hasNextPage: false,
data: {
pages: [{ data: [] }],
},
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
});
});
});

View File

@@ -83,7 +83,7 @@ describe('CategoryTabs', () => {
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('bg-surface-tertiary');
expect(generalTab).toHaveClass('bg-surface-hover');
// Should have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');

View File

@@ -0,0 +1,275 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { jest } from '@jest/globals';
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
import type * as t from 'librechat-data-provider';
// Mock react-virtualized for performance testing
const mockRowRenderer = jest.fn();
jest.mock('react-virtualized', () => {
const mockRowRendererRef = { current: jest.fn() };
return {
AutoSizer: ({
children,
disableHeight,
}: {
children: (props: { width: number; height?: number }) => React.ReactNode;
disableHeight?: boolean;
}) => {
if (disableHeight) {
return children({ width: 1200 });
}
return children({ width: 1200, height: 800 });
},
List: ({
rowRenderer,
rowCount,
autoHeight,
height,
width,
rowHeight,
overscanRowCount,
scrollTop,
isScrolling,
onScroll,
style,
'aria-rowcount': ariaRowCount,
'data-testid': dataTestId,
'data-total-rows': dataTotalRows,
}: {
rowRenderer: any;
rowCount: number;
[key: string]: any;
}) => {
// Store the row renderer for testing
if (typeof rowRenderer === 'function') {
mockRowRendererRef.current = rowRenderer;
mockRowRenderer.mockImplementation(rowRenderer);
}
// Only render visible rows to simulate virtualization
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
return (
<div
data-testid={dataTestId || 'virtual-list'}
data-total-rows={dataTotalRows || rowCount}
aria-rowcount={ariaRowCount}
style={style}
>
{Array.from({ length: visibleRows }, (_, index) =>
rowRenderer({
index,
key: `row-${index}`,
style: { height: 184 },
parent: { props: { width: width || 1200 } },
}),
)}
</div>
);
},
WindowScroller: ({
children,
scrollElement,
}: {
children: (props: any) => React.ReactNode;
scrollElement?: HTMLElement | null;
}) => {
return children({
height: 800,
isScrolling: false,
registerChild: (ref: any) => {},
onChildScroll: () => {},
scrollTop: 0,
});
},
};
});
// Generate large dataset for performance testing
const generateLargeDataset = (count: number) => {
const agents: Partial<t.Agent>[] = [];
for (let i = 1; i <= count; i++) {
agents.push({
id: `agent-${i}`,
name: `Performance Test Agent ${i}`,
description: `This is agent ${i} for performance testing virtual scrolling with large datasets`,
category: i % 2 === 0 ? 'productivity' : 'development',
});
}
return agents;
};
// Mock the data provider with large dataset
const createMockInfiniteQuery = (agentCount: number) => ({
data: {
pages: [{ data: generateLargeDataset(agentCount) }],
},
isLoading: false,
error: null,
isFetching: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
refetch: jest.fn(),
isFetchingNextPage: false,
});
// Mock must be hoisted before imports
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(),
}));
jest.mock('~/hooks', () => ({
useAgentCategories: () => ({
categories: [
{ value: 'productivity', label: 'Productivity' },
{ value: 'development', label: 'Development' },
],
}),
useLocalize: () => (key: string, params?: any) => {
if (key === 'com_agents_grid_announcement') {
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
}
return key;
},
}));
jest.mock('../SmartLoader', () => ({
useHasData: () => true,
}));
jest.mock('../AgentCard', () => {
return function MockAgentCard({ agent }: { agent: any }) {
return (
<div data-testid={`agent-card-${agent.id}`} style={{ height: '160px' }}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>
);
};
});
describe('Virtual Scrolling Performance', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockRowRenderer.mockClear();
});
const renderComponent = (agentCount: number) => {
const mockQuery = createMockInfiniteQuery(agentCount);
const useMarketplaceAgentsInfiniteQuery =
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
useMarketplaceAgentsInfiniteQuery.mockReturnValue(mockQuery);
// Clear previous mock calls
mockRowRenderer.mockClear();
return render(
<QueryClientProvider client={queryClient}>
<VirtualizedAgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</QueryClientProvider>,
);
};
it('efficiently handles 1000 agents without rendering all DOM nodes', () => {
const startTime = performance.now();
renderComponent(1000);
const endTime = performance.now();
const virtualList = screen.getByTestId('virtual-list');
expect(virtualList).toBeInTheDocument();
expect(virtualList).toHaveAttribute('data-total-rows', '500'); // 1000 agents / 2 per row
// Should only render visible cards, not all 1000
const renderedCards = screen.getAllByTestId(/agent-card-/);
expect(renderedCards.length).toBeLessThan(50); // Much less than 1000
expect(renderedCards.length).toBeGreaterThan(0);
// Performance check: rendering should be fast
const renderTime = endTime - startTime;
expect(renderTime).toBeLessThan(600); // Should render in less than 600ms
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
});
it('efficiently handles 5000 agents (stress test)', () => {
const startTime = performance.now();
renderComponent(5000);
const endTime = performance.now();
const virtualList = screen.getByTestId('virtual-list');
expect(virtualList).toBeInTheDocument();
expect(virtualList).toHaveAttribute('data-total-rows', '2500'); // 5000 agents / 2 per row
// Should still only render visible cards
const renderedCards = screen.getAllByTestId(/agent-card-/);
expect(renderedCards.length).toBeLessThan(50);
expect(renderedCards.length).toBeGreaterThan(0);
// Performance should still be reasonable
const renderTime = endTime - startTime;
expect(renderTime).toBeLessThan(200); // Should render in less than 200ms
console.log(`Rendered 5000 agents in ${renderTime.toFixed(2)}ms`);
console.log(`Only ${renderedCards.length} DOM nodes created for 5000 agents`);
});
it('calculates correct number of virtual rows for different screen sizes', () => {
// Test desktop layout (2 cards per row)
renderComponent(100);
const virtualList = screen.getByTestId('virtual-list');
expect(virtualList).toHaveAttribute('data-total-rows', '50'); // 100 agents / 2 per row
});
it('row renderer is called efficiently', () => {
// Reset the mock before testing
mockRowRenderer.mockClear();
renderComponent(1000);
// Check that virtual list was rendered
const virtualList = screen.getByTestId('virtual-list');
expect(virtualList).toBeInTheDocument();
// With virtualization, we should only render visible rows
// Our mock renders 10 visible rows max
const renderedCards = screen.getAllByTestId(/agent-card-/);
expect(renderedCards.length).toBeLessThanOrEqual(20); // At most 10 rows * 2 cards per row
expect(renderedCards.length).toBeGreaterThan(0);
});
it('memory usage remains stable with large datasets', () => {
// Test that memory doesn't grow linearly with data size
const measureMemory = () => {
const cards = screen.queryAllByTestId(/agent-card-/);
return cards.length;
};
renderComponent(100);
const memory100 = measureMemory();
renderComponent(1000);
const memory1000 = measureMemory();
renderComponent(5000);
const memory5000 = measureMemory();
// Memory usage should not scale linearly with data size
// All should render roughly the same number of DOM nodes
expect(Math.abs(memory100 - memory1000)).toBeLessThan(30);
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(30);
console.log(
`Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`,
);
});
});

View File

@@ -0,0 +1,248 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { jest } from '@jest/globals';
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
import type t from 'librechat-data-provider';
// Mock react-virtualized
jest.mock('react-virtualized', () => ({
AutoSizer: ({
children,
disableHeight,
}: {
children: (props: { width: number; height?: number }) => React.ReactNode;
disableHeight?: boolean;
}) => {
if (disableHeight) {
return children({ width: 800 });
}
return children({ width: 800, height: 600 });
},
List: ({
rowRenderer,
rowCount,
width,
style,
'aria-rowcount': ariaRowCount,
'data-testid': dataTestId,
'data-total-rows': dataTotalRows,
}: {
rowRenderer: any;
rowCount: number;
autoHeight?: boolean;
height?: number;
width?: number;
rowHeight?: number;
overscanRowCount?: number;
scrollTop?: number;
isScrolling?: boolean;
onScroll?: any;
style?: any;
'aria-rowcount'?: number;
'data-testid'?: string;
'data-total-rows'?: number;
}) => (
<div
data-testid={dataTestId || 'virtual-list'}
aria-rowcount={ariaRowCount}
data-total-rows={dataTotalRows}
style={style}
>
{Array.from({ length: Math.min(rowCount, 5) }, (_, index) =>
rowRenderer({
index,
key: `row-${index}`,
style: {},
parent: { props: { width: width || 800 } },
}),
)}
</div>
),
WindowScroller: ({
children,
}: {
children: (props: any) => React.ReactNode;
scrollElement?: HTMLElement | null;
}) => {
return children({
height: 600,
isScrolling: false,
registerChild: (_ref: any) => {},
onChildScroll: () => {},
scrollTop: 0,
});
},
}));
// Mock the data provider
const mockInfiniteQuery = {
data: {
pages: [
{
data: [
{
id: '1',
name: 'Test Agent 1',
description: 'A test agent for virtual scrolling',
category: 'productivity',
},
{
id: '2',
name: 'Test Agent 2',
description: 'Another test agent',
category: 'development',
},
],
},
],
},
isLoading: false,
error: null,
isFetching: false,
fetchNextPage: jest.fn(),
hasNextPage: true,
refetch: jest.fn(),
isFetchingNextPage: false,
};
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(() => mockInfiniteQuery),
}));
// Mock other hooks
jest.mock('~/hooks', () => ({
useAgentCategories: () => ({
categories: [
{ value: 'productivity', label: 'Productivity' },
{ value: 'development', label: 'Development' },
],
}),
useLocalize: () => (key: string, params?: any) => {
if (key === 'com_agents_grid_announcement') {
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
}
return key;
},
}));
jest.mock('../SmartLoader', () => ({
useHasData: () => true,
}));
jest.mock('../AgentCard', () => {
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
return (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>
);
};
});
describe('VirtualizedAgentGrid', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
const renderComponent = (props = {}) => {
const defaultProps = {
category: 'all',
searchQuery: '',
onSelectAgent: jest.fn(),
};
return render(
<QueryClientProvider client={queryClient}>
<VirtualizedAgentGrid {...defaultProps} {...props} />
</QueryClientProvider>,
);
};
it('renders virtual list container', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
});
});
it('displays agent cards in virtual rows', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
expect(screen.getByTestId('agent-card-2')).toBeInTheDocument();
});
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
});
it('calls onSelectAgent when agent card is clicked', async () => {
const onSelectAgent = jest.fn();
renderComponent({ onSelectAgent });
await waitFor(() => {
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
});
screen.getByTestId('agent-card-1').click();
expect(onSelectAgent).toHaveBeenCalledWith({
id: '1',
name: 'Test Agent 1',
description: 'A test agent for virtual scrolling',
category: 'productivity',
});
});
it('shows loading spinner when loading', async () => {
const mockQuery = jest.fn(() => ({
...mockInfiniteQuery,
isLoading: true,
data: undefined,
}));
const useMarketplaceAgentsInfiniteQuery =
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
useMarketplaceAgentsInfiniteQuery.mockImplementation(mockQuery);
renderComponent();
// Should show loading spinner
const spinner = document.querySelector('.spinner');
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass('h-8 w-8 text-primary');
});
it('has proper accessibility attributes', async () => {
// Reset the mock to ensure we have data
const useMarketplaceAgentsInfiniteQuery =
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
useMarketplaceAgentsInfiniteQuery.mockImplementation(() => mockInfiniteQuery);
renderComponent({ category: 'productivity' });
await waitFor(() => {
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
});
const gridContainer = screen.getByRole('grid');
expect(gridContainer).toHaveAttribute('aria-label');
expect(gridContainer.getAttribute('aria-label')).toContain('2');
expect(gridContainer.getAttribute('aria-label')).toContain('Productivity');
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
});
});

View File

@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import React, { memo, useEffect, useMemo, useCallback } from 'react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import {
useSandpack,
SandpackCodeEditor,
@@ -10,8 +10,8 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditorContext } from '~/Providers';
const createDebouncedMutation = (
callback: (params: {
@@ -29,18 +29,21 @@ const CodeEditor = ({
editorRef,
}: {
fileKey: string;
readOnly: boolean;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: () => {
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
@@ -71,8 +74,14 @@ const CodeEditor = ({
}
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
const isNotOriginal =
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdate == null
? true
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
if (artifact.content && isNotOriginal && isNotRepeated) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
@@ -92,8 +101,9 @@ const CodeEditor = ({
artifact.messageId,
readOnly,
isMutating,
sandpack.files,
currentUpdate,
setIsMutating,
sandpack.files,
setCurrentCode,
debouncedMutation,
]);
@@ -102,33 +112,32 @@ const CodeEditor = ({
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
readOnly={readOnly}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
className="hljs language-javascript bg-black"
/>
);
};
export const ArtifactCodeEditor = memo(function ({
export const ArtifactCodeEditor = function ({
files,
fileKey,
template,
artifact,
editorRef,
sharedProps,
isSubmitting,
}: {
fileKey: string;
artifact: Artifact;
files: ArtifactFiles;
isSubmitting: boolean;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
const options: typeof sharedOptions = useMemo(() => {
if (!config) {
return sharedOptions;
@@ -138,6 +147,10 @@ export const ArtifactCodeEditor = memo(function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
useEffect(() => {
setReadOnly(isSubmitting ?? false);
}, [isSubmitting]);
if (Object.keys(files).length === 0) {
return null;
@@ -154,12 +167,7 @@ export const ArtifactCodeEditor = memo(function ({
{...sharedProps}
template={template}
>
<CodeEditor
editorRef={editorRef}
fileKey={fileKey}
readOnly={isSubmitting}
artifact={artifact}
/>
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
</StyledProvider>
);
});
};

View File

@@ -2,12 +2,12 @@ import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { useEditorContext } from '~/Providers';
import { cn } from '~/utils';
export default function ArtifactTabs({
@@ -15,14 +15,13 @@ export default function ArtifactTabs({
isMermaid,
editorRef,
previewRef,
isSubmitting,
}: {
artifact: Artifact;
isMermaid: boolean;
isSubmitting: boolean;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useEditorContext();
const { data: startupConfig } = useGetStartupConfig();
const lastIdRef = useRef<string | null>(null);
@@ -52,7 +51,6 @@ export default function ArtifactTabs({
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
isSubmitting={isSubmitting}
/>
</Tabs.Content>
<Tabs.Content

View File

@@ -29,7 +29,6 @@ export default function Artifacts() {
isMermaid,
setActiveTab,
currentIndex,
isSubmitting,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
@@ -116,7 +115,6 @@ export default function Artifacts() {
<ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact}
isSubmitting={isSubmitting}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>

View File

@@ -1,6 +1,7 @@
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { OpenIDIcon } from '@librechat/client';
import { ErrorTypes } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
@@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
function Login() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { error, setError, login } = useAuthContext();
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
@@ -21,6 +23,19 @@ function Login() {
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
useEffect(() => {
const oauthError = searchParams?.get('error');
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
showToast({
message: localize('com_auth_error_oauth_failed'),
status: 'error',
});
const newParams = new URLSearchParams(searchParams);
newParams.delete('error');
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams, showToast, localize]);
// Once the disable flag is detected, update local state and remove the parameter from the URL.
useEffect(() => {
if (disableAutoRedirect) {

View File

@@ -5,11 +5,10 @@ import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
import { removeCharIfLast, detectVariables } from '~/utils';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { usePromptGroupsContext } from '~/Providers';
import { useLocalize, useHasAccess } from '~/hooks';
import { useGetAllPromptGroups } from '~/data-provider';
import MentionItem from './MentionItem';
import store from '~/store';
@@ -60,30 +59,8 @@ function PromptsCommand({
permission: Permissions.USE,
});
const { data, isLoading } = useGetAllPromptGroups(undefined, {
enabled: hasAccess,
select: (data) => {
const mappedArray = data.map((group) => ({
id: group._id,
value: group.command ?? group.name,
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.name
}: ${
(group.oneliner?.length ?? 0) > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
}));
const promptsMap = mapPromptGroups(data);
return {
promptsMap,
promptGroups: mappedArray,
};
},
});
const { allPromptGroups } = usePromptGroupsContext();
const { data, isLoading } = allPromptGroups;
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

View File

@@ -4,7 +4,7 @@ import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, SidePanelProvider } from '~/Providers';
import { EditorProvider, SidePanelProvider, ArtifactsProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanelGroup } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
@@ -66,9 +66,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>
<ArtifactsProvider>
<EditorProvider>
<Artifacts />
</EditorProvider>
</ArtifactsProvider>
) : null
}
>

View File

@@ -1,15 +0,0 @@
import { TPromptGroup } from 'librechat-data-provider';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
export default function PromptCard({ promptGroup }: { promptGroup?: TPromptGroup }) {
return (
<div className="hover:bg-token-main-surface-secondary relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-slate-100 dark:border-gray-600 dark:hover:bg-gray-700">
<div className="">
<CategoryIcon className="size-4" category={promptGroup?.category ?? ''} />
</div>
<p className="break-word line-clamp-3 text-balance text-gray-600 dark:text-gray-400">
{(promptGroup?.oneliner ?? '') || promptGroup?.productionPrompt?.prompt}
</p>
</div>
);
}

View File

@@ -1,96 +0,0 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { usePromptGroupsNav } from '~/hooks';
import PromptCard from './PromptCard';
import { Button } from '../ui';
export default function Prompts() {
const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } =
usePromptGroupsNav();
const renderPromptCards = (start = 0, count) => {
return promptGroups
.slice(start, count + start)
.map((promptGroup) => <PromptCard key={promptGroup._id} promptGroup={promptGroup} />);
};
const getRows = () => {
switch (pageSize) {
case 4:
return [4];
case 8:
return [4, 4];
case 12:
return [4, 4, 4];
default:
return [];
}
};
const rows = getRows();
return (
<div className="mx-3 flex h-full max-w-3xl flex-col items-stretch justify-center gap-4">
<div className="mt-2 flex justify-end gap-2">
<Button
variant={'ghost'}
onClick={() => setPageSize(4)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 4 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
4
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(8)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 8 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
8
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(12)}
className={`rounded p-2 hover:bg-transparent ${
pageSize === 12 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
12
</Button>
</div>
<div className="flex h-full flex-col items-start gap-2">
<div
className={
'flex min-h-[121.1px] min-w-full max-w-3xl flex-col gap-4 overflow-y-auto md:min-w-[22rem] lg:min-w-[43rem]'
}
>
{rows.map((rowSize, index) => (
<div key={index} className="flex flex-wrap justify-center gap-4">
{renderPromptCards(rowSize * index, rowSize)}
</div>
))}
</div>
<div className="flex w-full justify-between">
<Button
variant={'ghost'}
onClick={prevPage}
disabled={!hasPreviousPage}
className="m-0 self-start p-0 hover:bg-transparent"
aria-label="previous"
>
<ChevronLeft className={`${hasPreviousPage ? '' : 'text-gray-500'}`} />
</Button>
<Button
variant={'ghost'}
onClick={nextPage}
disabled={!hasNextPage}
className="m-0 self-end p-0 hover:bg-transparent"
>
<ChevronRight className={`${hasNextPage ? '' : 'text-gray-500'}`} />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -25,7 +25,7 @@ type EndpointIcon = {
function getOpenAIColor(_model: string | null | undefined) {
const model = _model?.toLowerCase() ?? '';
if (model && /\b(o\d)\b/i.test(model)) {
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) {
return '#000000';
}
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';

View File

@@ -32,13 +32,14 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
<div className="space-y-2">
<div className="flex items-center justify-between">
<TooltipAnchor
enableHTML={true}
description={config.description || ''}
render={
<div className="flex items-center gap-2">
<Label htmlFor={name} className="text-sm font-medium">
{config.title}
</Label>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />
<CircleHelpIcon className="h-6 w-6 cursor-help text-text-secondary transition-colors hover:text-text-primary" />
</div>
}
/>

View File

@@ -1,6 +1,5 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
import type { TOpenAIMessage } from 'librechat-data-provider';
import type { LocalizeFunction } from '~/common';
import { formatJSON, extractJson, isJson } from '~/utils/json';
import { useLocalize } from '~/hooks';
@@ -25,7 +24,7 @@ type TTokenBalance = {
prev_count: number;
violation_count: number;
date: Date;
generations?: TOpenAIMessage[];
generations?: unknown[];
};
type TExpiredKey = {
@@ -44,6 +43,17 @@ const errorMessages = {
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
const { info: endpoint } = json;
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_missing_model', { 0: provider });
},
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
const { info: endpoint } = json;
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
},
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
const { expiredAt, endpoint } = json;
@@ -65,6 +75,12 @@ const errorMessages = {
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
[ViolationTypes.BAN]:
'Your account has been temporarily banned due to violations of our service.',
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
const { info } = json;
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
},
invalid_api_key:
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
insufficient_quota:

View File

@@ -92,7 +92,26 @@ export default function NewChat({
}
/>
<div className="flex">
{showAgentMarketplace && (
<div className="flex">
<TooltipAnchor
description={localize('com_agents_marketplace')}
render={
<Button
variant="outline"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_agents_marketplace')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
onClick={handleAgentMarketplace}
>
<LayoutGrid className="icon-md md:h-6 md:w-6" />
</Button>
}
/>
</div>
)}
{headerButtons}
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
@@ -110,29 +129,6 @@ export default function NewChat({
/>
</div>
</div>
{/* Agent Marketplace button - separate row like ChatGPT */}
{showAgentMarketplace && (
<div className="flex">
<TooltipAnchor
description={localize('com_agents_marketplace')}
render={
<Button
variant="outline"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_agents_marketplace')}
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
onClick={handleAgentMarketplace}
>
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-sm font-medium">
{localize('com_agents_marketplace')}
</span>
</Button>
}
/>
</div>
)}
{subHeaders != null ? subHeaders : null}
</>
);

View File

@@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
import { useAuthContext } from '~/hooks';
function Account() {
const user = useAuthContext();
const { user } = useAuthContext();
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
@@ -17,12 +17,12 @@ function Account() {
<div className="pb-3">
<Avatar />
</div>
{user?.user?.provider === 'local' && (
{user?.provider === 'local' && (
<>
<div className="pb-3">
<EnableTwoFactorItem />
</div>
{user?.user?.twoFactorEnabled && (
{user?.twoFactorEnabled && (
<div className="pb-3">
<BackupCodesItem />
</div>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Switch, Label } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, Label, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -17,7 +16,7 @@ export default function DisplayUsernameMessages() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
</div>
<Switch
id="UsernameDisplay"

View File

@@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
const [secret, setSecret] = useState<string>('');
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
const [downloaded, setDownloaded] = useState<boolean>(false);
const [disableToken, setDisableToken] = useState<string>('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [_disableToken, setDisableToken] = useState<string>('');
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string>('');
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
@@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
payload.token = token.trim();
}
verify2FAMutate(payload, {
disable2FAMutate(payload, {
onSuccess: () => {
disable2FAMutate(undefined, {
onSuccess: () => {
showToast({ message: localize('com_ui_2fa_disabled') });
setDialogOpen(false);
setUser(
(prev) =>
({
...prev,
totpSecret: '',
backupCodes: [],
twoFactorEnabled: false,
}) as TUser,
);
setPhase('setup');
setOtpauthUrl('');
},
onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
});
showToast({ message: localize('com_ui_2fa_disabled') });
setDialogOpen(false);
setUser(
(prev) =>
({
...prev,
totpSecret: '',
backupCodes: [],
twoFactorEnabled: false,
}) as TUser,
);
setPhase('setup');
setOtpauthUrl('');
},
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
});
},
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
[disable2FAMutate, showToast, localize, setUser],
);
return (

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Label } from '@librechat/client';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Label, InfoHoverCard, ESide } from '@librechat/client';
import { TranslationKeys, useLocalize } from '~/hooks';
interface AutoRefillSettingsProps {
@@ -114,7 +113,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
<HoverCardSettings side="bottom" text="com_nav_balance_next_refill_info" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_balance_next_refill_info')} />
</div>
{/* Right Section: tokenCredits Value */}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Label } from '@librechat/client';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Label, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface TokenCreditsItemProps {
@@ -15,7 +14,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) =>
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance')}</Label>
<HoverCardSettings side="bottom" text="com_nav_info_balance" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_balance')} />
</div>
{/* Right Section: tokenCredits Value */}

View File

@@ -1,7 +1,6 @@
import { useRecoilState } from 'recoil';
import { Dropdown, Switch } from '@librechat/client';
import { ForkOptions } from 'librechat-data-provider';
import HoverCardSettings from '../HoverCardSettings';
import { Dropdown, Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -36,7 +35,10 @@ export const ForkSettings = () => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_fork_change_default" />
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')}
/>
</div>
<Dropdown
value={forkSetting}
@@ -53,7 +55,10 @@ export const ForkSettings = () => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_fork_split_target_setting" />
<InfoHoverCard
side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')}
/>
</div>
<Switch
id="splitAtTarget"

View File

@@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -23,7 +22,7 @@ export default function SaveBadgesState({
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_save_badges_state')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_badges_state" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_save_badges_state')} />
</div>
<Switch
id="saveBadgesState"

View File

@@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import HoverCardSettings from '../HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -23,7 +22,7 @@ export default function SaveDraft({
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_show_thinking')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_show_thinking" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_show_thinking')} />
</div>
<Switch
id="showThinking"

View File

@@ -1,8 +1,8 @@
import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { useLocalize, useHasAccess } from '~/hooks';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';
@@ -25,7 +25,7 @@ function Commands() {
<h3 className="text-lg font-medium text-text-primary">
{localize('com_nav_chat_commands')}
</h3>
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="pb-3">

View File

@@ -1,9 +1,8 @@
import { forwardRef } from 'react';
import type { ForwardedRef } from 'react';
import { CheckIcon } from 'lucide-react';
import { Spinner, DialogButton } from '@librechat/client';
import HoverCardSettings from './HoverCardSettings';
import { Spinner, DialogButton, InfoHoverCard, ESide } from '@librechat/client';
import type { TDangerButtonProps } from '~/common';
import type { ForwardedRef } from 'react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -37,7 +36,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
{showText && (
<div className={`flex items-center ${infoDescriptionCode ? 'space-x-2' : ''}`}>
<div>{localize(infoTextCode)}</div>
{infoDescriptionCode && <HoverCardSettings side="bottom" text={infoDescriptionCode} />}
{infoDescriptionCode && <InfoHoverCard side={ESide.Bottom} text={infoDescriptionCode} />}
</div>
)}
<DialogButton

View File

@@ -1,30 +0,0 @@
import React from 'react';
import {
CircleHelpIcon,
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
const HoverCardSettings = ({ side, text }) => {
const localize = useLocalize();
return (
<HoverCard openDelay={500}>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize(text)}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
};
export default HoverCardSettings;

View File

@@ -1,12 +1,14 @@
import { Switch } from '@librechat/client';
import { RecoilState, useRecoilState } from 'recoil';
import HoverCardSettings from './HoverCardSettings';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
type LocalizeFn = ReturnType<typeof useLocalize>;
type LocalizeKey = Parameters<LocalizeFn>[0];
interface ToggleSwitchProps {
stateAtom: RecoilState<boolean>;
localizationKey: string;
hoverCardText?: string;
localizationKey: LocalizeKey;
hoverCardText?: LocalizeKey;
switchId: string;
onCheckedChange?: (value: boolean) => void;
}
@@ -18,21 +20,19 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
switchId,
onCheckedChange,
}) => {
const [switchState, setSwitchState] = useRecoilState<boolean>(stateAtom);
const [switchState, setSwitchState] = useRecoilState(stateAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSwitchState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
onCheckedChange?.(value);
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize(localizationKey as any)}</div>
{hoverCardText && <HoverCardSettings side="bottom" text={hoverCardText} />}
<div>{localize(localizationKey)}</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div>
<Switch
id={switchId}

View File

@@ -153,7 +153,7 @@ const AdminSettings = () => {
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-lg border-border-light bg-surface-primary text-text-primary">
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary md:w-1/4">
<OGDialogTitle>
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
</OGDialogTitle>

View File

@@ -4,7 +4,7 @@ import { SystemCategories } from 'librechat-data-provider';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
import type { Option } from '~/common';
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
import { useLocalize, useCategories, usePromptGroupsNav } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';

View File

@@ -4,7 +4,7 @@ import { useMediaQuery } from '@librechat/client';
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import List from '~/components/Prompts/Groups/List';
import { usePromptGroupsNav } from '~/hooks';
import type { usePromptGroupsNav } from '~/hooks';
import { cn } from '~/utils';
export default function GroupSidePanel({

View File

@@ -6,7 +6,12 @@ import { Menu, Rocket } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useParams, useOutletContext } from 'react-router-dom';
import { Button, Skeleton, useToastContext } from '@librechat/client';
import { Permissions, PermissionTypes, PermissionBits } from 'librechat-data-provider';
import {
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import {
useGetPrompts,
@@ -173,16 +178,14 @@ const PromptForm = () => {
const [showSidePanel, setShowSidePanel] = useState(false);
const sidePanelWidth = '320px';
// Fetch group early so it is available for later hooks.
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
{ groupId: promptId },
{ enabled: !!promptId },
);
// Check permissions for the promptGroup
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'promptGroup',
ResourceType.PROMPTGROUP,
group?._id || '',
);

View File

@@ -1,10 +1,10 @@
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import { usePromptGroupsNav } from '~/hooks';
import { usePromptGroupsContext } from '~/Providers';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsNav();
const groupsNav = usePromptGroupsContext();
return (
<div className="flex h-full w-full flex-col">
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>

View File

@@ -3,14 +3,15 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
import GroupSidePanel from './Groups/GroupSidePanel';
import { usePromptGroupsContext } from '~/Providers';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
export default function PromptsView() {
const params = useParams();
const navigate = useNavigate();
const groupsNav = usePromptGroupsNav();
const groupsNav = usePromptGroupsContext();
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,

View File

@@ -1,7 +1,7 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { DropdownPopup } from '@librechat/client';
import { DropdownPopup, Skeleton } from '@librechat/client';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import type { AccessRole } from 'librechat-data-provider';
@@ -10,6 +10,7 @@ import { cn, getRoleLocalizationKeys } from '~/utils';
import { useLocalize } from '~/hooks';
interface AccessRolesPickerProps {
id?: string;
resourceType?: ResourceType;
selectedRoleId?: AccessRoleIds;
onRoleChange: (roleId: AccessRoleIds) => void;
@@ -17,6 +18,7 @@ interface AccessRolesPickerProps {
}
export default function AccessRolesPicker({
id,
resourceType = ResourceType.AGENT,
selectedRoleId = AccessRoleIds.AGENT_VIEWER,
onRoleChange,
@@ -39,14 +41,7 @@ export default function AccessRolesPicker({
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
if (rolesLoading || !accessRoles) {
return (
<div className={className}>
<div className="flex items-center justify-center py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border-light border-t-blue-600"></div>
<span className="ml-2 text-sm text-text-secondary">{localize('com_ui_loading')}</span>
</div>
</div>
);
return <Skeleton className="h-10 w-24 rounded-lg" />;
}
const dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {
@@ -70,7 +65,7 @@ export default function AccessRolesPicker({
});
return (
<div className={className}>
<div className={className} id={id}>
<DropdownPopup
menuId="access-roles-menu"
isOpen={isOpen}
@@ -79,8 +74,7 @@ export default function AccessRolesPicker({
<Ariakit.MenuButton
aria-label={selectedRoleInfo?.description || 'Select role'}
className={cn(
'flex items-center justify-between gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-2 text-sm transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring-primary',
'min-w-[200px]',
'flex items-center justify-between gap-2 rounded-xl border border-border-light bg-transparent px-3 py-2 text-sm transition-colors hover:bg-surface-tertiary',
)}
>
<span className="font-medium">

View File

@@ -1,8 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react';
import {
Label,
Button,
Spinner,
Skeleton,
OGDialog,
OGDialogTitle,
OGDialogClose,
@@ -17,11 +20,11 @@ import {
useCopyToClipboard,
useLocalize,
} from '~/hooks';
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
import PeoplePickerAdminSettings from './PeoplePickerAdminSettings';
import PublicSharingToggle from './PublicSharingToggle';
import AccessRolesPicker from './AccessRolesPicker';
import { cn, removeFocusOutlines } from '~/utils';
import { PeoplePicker } from './PeoplePicker';
import { SelectedPrincipalsList } from './PeoplePicker';
import { cn } from '~/utils';
export default function GenericGrantAccessDialog({
resourceName,
@@ -49,6 +52,9 @@ export default function GenericGrantAccessDialog({
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
const {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
@@ -59,11 +65,22 @@ export default function GenericGrantAccessDialog({
setPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
// State for unified list of all shares (existing + newly added)
const [allShares, setAllShares] = useState<TPrincipal[]>([]);
const [hasChanges, setHasChanges] = useState(false);
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds | undefined>(
config?.defaultViewerRoleId,
);
// Sync all shares with current shares when modal opens, marking existing vs new
useEffect(() => {
if (permissionsData && isModalOpen) {
const shares = permissionsData.principals || [];
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
@@ -76,21 +93,88 @@ export default function GenericGrantAccessDialog({
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
// Handler for adding users from search (immediate add to unified list)
const handleAddFromSearch = (newShares: TPrincipal[]) => {
const sharesToAdd = newShares.filter(
(newShare) =>
!allShares.some((existing) => existing.idOnTheSource === newShare.idOnTheSource),
);
const allShares = [...currentShares, ...sharesToAdd];
const sharesWithDefaults = sharesToAdd.map((share) => ({
...share,
accessRoleId: defaultPermissionId || config?.defaultViewerRoleId,
isExisting: false, // Mark as newly added
}));
setAllShares((prev) => [...prev, ...sharesWithDefaults]);
setHasChanges(true);
};
// Handler for removing individual shares
const handleRemoveShare = (idOnTheSource: string) => {
setAllShares(allShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
// Handler for changing individual share permissions
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
setAllShares(
allShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole as AccessRoleIds } : s,
),
);
setHasChanges(true);
};
// Handler for public access toggle
const handlePublicToggle = (isPublicValue: boolean) => {
setIsPublic(isPublicValue);
setHasChanges(true);
if (!isPublicValue) {
setPublicRole(config?.defaultViewerRoleId);
}
};
// Handler for public role change
const handlePublicRoleChange = (role: string) => {
setPublicRole(role as AccessRoleIds);
setHasChanges(true);
};
// Save all changes (unified save handler)
const handleSave = async () => {
if (!allShares.length && !isPublic && !hasChanges) {
return;
}
try {
// Calculate changes for unified list
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const allSharesMap = new Map(
allShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
// Find newly added and updated shares
const updated = allShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
// Find removed shares
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !allSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated: sharesToAdd,
removed: [],
updated,
removed,
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
@@ -101,65 +185,74 @@ export default function GenericGrantAccessDialog({
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setIsModalOpen(false);
setHasChanges(false);
} catch (error) {
console.error('Error granting access:', error);
console.error('Error updating permissions:', error);
showToast({
message: 'Failed to grant access. Please try again.',
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
// Reset to original state
const shares = permissionsData?.principals || [];
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setIsPublic(currentIsPublic);
setPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
setHasChanges(false);
setIsModalOpen(false);
};
// Validation and calculated values
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
// Check if there's at least one owner (user, group, or public with owner role)
const hasAtLeastOneOwner =
allShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
(isPublic && publicRole === config?.defaultOwnerRoleId);
// Check if there are any changes to save
const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole;
const submitButtonActive = hasChanges || hasPublicChanges;
// Error handling
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const TriggerComponent = children ? (
children
) : (
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
<Button
size="sm"
variant="outline"
aria-label={localize('com_ui_share_var', {
0: config?.getShareMessage(resourceName),
})}
type="button"
disabled={disabled}
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
<span className="flex h-6 w-6 items-center justify-center">
<Share2Icon className="icon-md h-4 w-4" />
</span>
{totalCurrentShares > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{totalCurrentShares}
</span>
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
)}
</div>
</button>
</Button>
);
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
@@ -171,51 +264,88 @@ export default function GenericGrantAccessDialog({
</OGDialogTitle>
<div className="space-y-6 p-2">
{hasPeoplePickerAccess && (
<>
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
{/* Unified Search and Management Section */}
<div className="space-y-4">
{/* Search Bar with Default Permission Setting */}
{hasPeoplePickerAccess && (
<div className="space-y-2">
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ( {allShares.length} )
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
<UnifiedPeopleSearch
onAddPeople={handleAddFromSearch}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
excludeIds={allShares.map((s) => s.idOnTheSource)}
/>
{/* Unified User/Group List */}
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex flex-col items-center gap-2">
<Skeleton className="h-[62px] w-full rounded-lg" />
<Skeleton className="h-[62px] w-full rounded-lg" />
</div>
);
}
if (allShares.length === 0 && !hasChanges) {
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-primary" />
<p className="mt-2 text-sm text-text-primary">
{localize('com_ui_no_individual_access')}
</p>
<p className="mt-1 text-xs text-text-primary">
{localize('com_ui_search_above_to_add_people')}
</p>
</div>
);
}
return (
<div className="space-y-2">
{!hasAtLeastOneOwner && hasChanges && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
<UserX className="h-4 w-4" />
{localize('com_ui_at_least_one_owner_required')}
</div>
</div>
)}
<SelectedPrincipalsList
principles={allShares}
onRemoveHandler={handleRemoveShare}
resourceType={resourceType}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
})()}
</div>
</>
)}
)}
</div>
<div className="flex border-t border-border-light" />
{/* Public Access Section */}
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
resourceType={resourceType}
/>
<div className="flex justify-between border-t pt-4">
{/* Footer Actions */}
<div className="flex justify-between pt-4">
<div className="flex gap-2">
{hasPeoplePickerAccess && (
<GenericManagePermissionsDialog
resourceDbId={resourceDbId}
resourceName={resourceName}
resourceType={resourceType}
/>
)}
{resourceId && resourceUrl && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (isCopying) return;
copyResourceUrl(setIsCopying);
@@ -237,24 +367,29 @@ export default function GenericGrantAccessDialog({
</Button>
)}
</div>
<div className="flex gap-3">
<div className="flex gap-2">
<PeoplePickerAdminSettings />
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
onClick={handleSave}
disabled={
updatePermissionsMutation.isLoading ||
!submitButtonActive ||
(hasChanges && !hasAtLeastOneOwner)
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_granting')}
<Spinner className="h-4 w-4" />
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_grant_access')
localize('com_ui_save_changes')
)}
</Button>
</div>

View File

@@ -1,349 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TPrincipal, ResourceType, AccessRoleIds } from 'librechat-data-provider';
import { useResourcePermissionState } from '~/hooks/Sharing';
import PublicSharingToggle from './PublicSharingToggle';
import { SelectedPrincipalsList } from './PeoplePicker';
import { cn, removeFocusOutlines } from '~/utils';
import { useLocalize } from '~/hooks';
export default function GenericManagePermissionsDialog({
resourceDbId,
resourceName,
resourceType,
onUpdatePermissions,
children,
}: {
resourceDbId: string;
resourceName?: string;
resourceType: ResourceType;
onUpdatePermissions?: (
shares: TPrincipal[],
isPublic: boolean,
publicRole?: AccessRoleIds,
) => void;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
currentPublicRole,
isPublic: managedIsPublic,
setIsPublic: setManagedIsPublic,
publicRole: managedPublicRole,
setPublicRole: setManagedPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const { data: accessRoles } = useGetAccessRolesQuery(resourceType);
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
useEffect(() => {
if (permissionsData && isModalOpen) {
const shares = permissionsData.principals || [];
setManagedShares(shares);
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
if (!resourceDbId) {
return null;
}
if (!config) {
console.error(`Unsupported resource type: ${resourceType}`);
return null;
}
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const handleRemoveShare = (idOnTheSource: string) => {
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
const handleRoleChange = (idOnTheSource: string, newRole: AccessRoleIds) => {
setManagedShares(
managedShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
),
);
setHasChanges(true);
};
const handleSaveChanges = async () => {
try {
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const managedSharesMap = new Map(
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const updated = managedShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !managedSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated,
removed,
public: managedIsPublic,
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
},
});
if (onUpdatePermissions) {
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
}
showToast({
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setIsModalOpen(false);
} catch (error) {
console.error('Error updating permissions:', error);
showToast({
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setManagedShares(currentShares);
setManagedIsPublic(currentIsPublic);
setManagedPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
setIsModalOpen(false);
};
const handleRevokeAll = () => {
setManagedShares([]);
setManagedIsPublic(false);
setHasChanges(true);
};
const handlePublicToggle = (isPublic: boolean) => {
setManagedIsPublic(isPublic);
setHasChanges(true);
if (!isPublic) {
setManagedPublicRole(config?.defaultViewerRoleId);
}
};
const handlePublicRoleChange = (role: AccessRoleIds) => {
setManagedPublicRole(role);
setHasChanges(true);
};
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
const originalTotalShares = currentShares.length + (currentIsPublic ? 1 : 0);
/** Check if there's at least one owner (user, group, or public with owner role) */
const hasAtLeastOneOwner =
managedShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
(managedIsPublic && managedPublicRole === config?.defaultOwnerRoleId);
let peopleLabel = localize('com_ui_people');
if (managedShares.length === 1) {
peopleLabel = localize('com_ui_person');
}
const buttonAriaLabel = config?.getManageMessage(resourceName);
const dialogTitle = config?.getManageMessage(resourceName);
let publicSuffix = '';
if (managedIsPublic) {
publicSuffix = localize('com_ui_and_public');
}
const TriggerComponent = children ? (
children
) : (
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={buttonAriaLabel}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Settings className="icon-md h-4 w-4" />
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
{originalTotalShares > 0 && `(${originalTotalShares})`}
</div>
</button>
);
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
{dialogTitle}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
<div className="rounded-lg bg-surface-tertiary p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-text-primary">
{localize('com_ui_current_access')}
</h3>
<p className="text-xs text-text-secondary">
{(() => {
if (totalShares === 0) {
return localize('com_ui_no_users_groups_access');
}
return localize('com_ui_shared_with_count', {
0: managedShares.length,
1: peopleLabel,
2: publicSuffix,
});
})()}
</p>
</div>
{(managedShares.length > 0 || managedIsPublic) && (
<Button
variant="outline"
size="sm"
onClick={handleRevokeAll}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="mr-2 h-4 w-4" />
{localize('com_ui_revoke_all')}
</Button>
)}
</div>
</div>
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
</div>
);
}
if (managedShares.length > 0) {
return (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ({managedShares.length})
</h3>
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
}
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-secondary" />
<p className="mt-2 text-sm text-text-secondary">
{localize('com_ui_no_individual_access')}
</p>
</div>
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
resourceType={resourceType}
/>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleSaveChanges}
disabled={
updatePermissionsMutation.isLoading ||
!hasChanges ||
isLoadingPermissions ||
!hasAtLeastOneOwner
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_save_changes')
)}
</Button>
</div>
{hasChanges && (
<div className="text-xs text-orange-600 dark:text-orange-400">
* {localize('com_ui_unsaved_changes')}
</div>
)}
{!hasAtLeastOneOwner && hasChanges && (
<div className="text-xs text-red-600 dark:text-red-400">
* {localize('com_ui_at_least_one_owner_required')}
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -1,273 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ResourceType, AccessRoleIds } from 'librechat-data-provider';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import {
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard, usePeoplePickerPermissions } from '~/hooks';
import ManagePermissionsDialog from './ManagePermissionsDialog';
import PublicSharingToggle from './PublicSharingToggle';
import AccessRolesPicker from './AccessRolesPicker';
import { cn, removeFocusOutlines } from '~/utils';
import { PeoplePicker } from './PeoplePicker';
export default function GrantAccessDialog({
agentName,
onGrantAccess,
resourceType = ResourceType.AGENT,
agentDbId,
agentId,
}: {
agentDbId?: string | null;
agentId?: string | null;
agentName?: string;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: AccessRoleIds) => void;
resourceType?: ResourceType;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
const {
data: permissionsData,
// isLoading: isLoadingPermissions,
// error: permissionsError,
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
enabled: !!agentDbId,
});
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds>(
AccessRoleIds.AGENT_VIEWER,
);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const agentUrl = `${window.location.origin}/c/new?agent_id=${agentId}`;
const copyAgentUrl = useCopyToClipboard({ text: agentUrl });
const currentShares: TPrincipal[] =
permissionsData?.principals?.map((principal) => ({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
accessRoleId: principal.accessRoleId,
})) || [];
const currentIsPublic = permissionsData?.public ?? false;
const currentPublicRole = permissionsData?.publicAccessRoleId || AccessRoleIds.AGENT_VIEWER;
const [isPublic, setIsPublic] = useState(false);
const [publicRole, setPublicRole] = useState<AccessRoleIds>(AccessRoleIds.AGENT_VIEWER);
useEffect(() => {
if (permissionsData && isModalOpen) {
setIsPublic(currentIsPublic ?? false);
setPublicRole(currentPublicRole);
}
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
if (!agentDbId) {
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
const allShares = [...currentShares, ...sharesToAdd];
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: agentDbId,
data: {
updated: sharesToAdd,
removed: [],
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
});
if (onGrantAccess) {
onGrantAccess(allShares, isPublic, publicRole);
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(AccessRoleIds.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(AccessRoleIds.AGENT_VIEWER);
setIsModalOpen(false);
} catch (error) {
console.error('Error granting access:', error);
showToast({
message: 'Failed to grant access. Please try again.',
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
setDefaultPermissionId(AccessRoleIds.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(AccessRoleIds.AGENT_VIEWER);
setIsModalOpen(false);
};
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize('com_ui_share_var', {
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
{totalCurrentShares > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{totalCurrentShares}
</span>
)}
</div>
</button>
</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
{localize('com_ui_share_var', {
0:
agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
{hasPeoplePickerAccess && (
<>
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div>
</>
)}
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
resourceType={resourceType}
/>
<div className="flex justify-between border-t pt-4">
<div className="flex gap-2">
{hasPeoplePickerAccess && (
<ManagePermissionsDialog
agentDbId={agentDbId}
agentName={agentName}
resourceType={resourceType}
/>
)}
{agentId && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (isCopying) return;
copyAgentUrl(setIsCopying);
showToast({
message: localize('com_ui_agent_url_copied'),
status: 'success',
});
}}
disabled={isCopying}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_url_to_clipboard')}
title={
isCopying
? localize('com_ui_agent_url_copied')
: localize('com_ui_copy_url_to_clipboard')
}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
</Button>
)}
</div>
<div className="flex gap-3">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_granting')}
</div>
) : (
localize('com_ui_grant_access')
)}
</Button>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import { Settings, Users, UserCheck, Trash2, Shield } from 'lucide-react';
import {
useGetAccessRolesQuery,
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import type { TPrincipal } from 'librechat-data-provider';
import {
Button,
Spinner,
OGDialog,
OGDialogTitle,
OGDialogClose,
@@ -46,10 +46,6 @@ export default function ManagePermissionsDialog({
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
enabled: !!agentDbId,
});
const {
data: accessRoles,
// isLoading,
} = useGetAccessRolesQuery(resourceType);
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
@@ -267,7 +263,7 @@ export default function ManagePermissionsDialog({
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<Spinner className="h-6 w-6" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
@@ -285,7 +281,7 @@ export default function ManagePermissionsDialog({
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
resourceType={resourceType}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
@@ -302,17 +298,12 @@ export default function ManagePermissionsDialog({
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
/>
</div>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
/>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
@@ -332,7 +323,7 @@ export default function ManagePermissionsDialog({
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
<Spinner className="h-4 w-4" />
{localize('com_ui_saving')}
</div>
) : (

Some files were not shown because too many files have changed in this diff Show More