Compare commits

...

47 Commits

Author SHA1 Message Date
Ruben Talstra
edaa357e9d Merge branch 'dev' into feat/Multitenant-login-OIDC 2025-05-22 10:50:18 +02:00
Ruben Talstra
bba4184b2c Merge branch 'main' into feat/Multitenant-login-OIDC 2025-05-22 10:49:49 +02:00
matt burnett
b64265e5bf feat: Agent Version History and Management (#7455)
*  feat: Enhance agent update functionality to save current state in versions array

- Updated the `updateAgent` function to push the current agent's state into a new `versions` array when an agent is updated.
- Modified the agent schema to include a `versions` field for storing historical states of agents.

*  feat: Add comprehensive CRUD operations for agents in tests

- Introduced a new test suite for CRUD operations on agents, including create, read, update, and delete functionalities.
- Implemented tests for listing agents by author and updating agent projects.
- Enhanced the agent model to support version history tracking during updates.
- Ensured proper environment variable management during tests.

*  feat: Introduce version tracking for agents and enhance UI components

- Added a `version` property to the agent model to track the number of versions.
- Updated the `getAgentHandler` to include the agent's version in the response.
- Introduced a new `VersionButton` component for navigating to the version panel.
- Created a `VersionPanel` component for displaying version-related information.
- Updated the UI to conditionally render the version button and panel based on the active state.
- Added localization for the new version-related UI elements.

*  i18n: Add "version" translation key across multiple languages

- Introduced the "com_ui_agent_version" translation key in various language files to support version tracking for agents.
- Updated Arabic, Czech, German, English, Spanish, Estonian, Persian, Finnish, French, Hebrew, Hungarian, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil and Portugal), Russian, Swedish, Thai, Turkish, Vietnamese, and Chinese (Simplified and Traditional) translations.

*  feat: Update AgentFooter to conditionally render AdminSettings

- Modified the logic for displaying buttons in the AgentFooter component to only show them when the active panel is the builder.
- Ensured that AdminSettings is displayed only when the user has an admin role and the buttons are visible.

*  feat: Enhance AgentPanelSwitch and VersionPanel for improved agent capabilities

- Updated AgentPanelSwitch to include a new VersionPanel for displaying version-related information.
- Enhanced agentsConfig logic to properly handle agent capabilities.
- Modified VersionPanel to improve structure and localization support.
- Integrated createAgent mutation for future agent creation functionality.

*  feat: Enhance VersionPanel to display agent version history and loading states

- Integrated version fetching logic in VersionPanel to retrieve and display agent version history.
- Added loading and error handling states to improve user experience.
- Updated agent schema to use mixed types for versions, allowing for more flexible version data structures.
- Introduced localization support for version-related UI elements.

*  feat: Update VersionPanel and AgentPanelSwitch to enhance agent selection and version display

- Modified AgentPanelSwitch to pass selectedAgentId to VersionPanel for improved agent context.
- Enhanced VersionPanel to handle multiple timestamp formats and display appropriate messages when no agent is selected.
- Improved structure and readability of the VersionPanel component by adding a helper function for timestamp retrieval.

*  feat: Refactor VersionPanel to utilize localization and improve timestamp handling

- Replaced hardcoded text constants with localization support for various UI elements in VersionPanel.
- Enhanced the timestamp retrieval function to handle errors gracefully and utilize localized messages for unknown dates.
- Improved user feedback by displaying localized messages for agent selection, version errors, and empty states.

*  refactor: Clean up VersionPanel by removing unused code and improving timestamp handling

*  feat: Implement agent version reverting functionality

- Added `revertAgentVersion` method in the Agent model to allow reverting to a previous version of an agent.
- Introduced `revertAgentVersionHandler` in the agents controller to handle requests for reverting agent versions.
- Updated API routes to include a new endpoint for reverting agent versions.
- Enhanced the VersionPanel component to support version restoration with user confirmation and feedback.
- Added localization support for success and error messages related to version restoration.

*  i18n: Add localization for agent version restoration messages

* Simplify VersionPanel by removing unused parameters and enhancing agent ID handling

* Refactor Agent model and VersionPanel component to streamline version data handling

* Update version handling in Agent model and VersionPanel

- Enhanced the Agent model to include an `updatedAt` timestamp when pushing new versions.
- Improved the VersionPanel component to sort versions by the `updatedAt` timestamp for better display order.
- Added a new localization entry for indicating the active version of an agent.

*  i18n: Add localization for active agent version across multiple languages

*  feat: Introduce version management components for agent history

- Added `isActiveVersion` utility to determine the active version of an agent based on various criteria.
- Implemented `VersionContent` and `VersionItem` components to display agent version history, including loading and error states.
- Enhanced `VersionPanel` to integrate new components and manage version context effectively.
- Added comprehensive tests for version management functionalities to ensure reliability and correctness.

* Add unit tests for AgentFooter component

* cleanup

* Enhance agent version update handling and add unit tests for update operators

- Updated the `updateAgent` function to properly handle various update operators ($push, $pull, $addToSet) while maintaining version history.
- Modified unit tests to validate the correct behavior of agent updates, including versioning and tool management.

* Enhance version comparison logic and update tests for artifacts handling

- Modified the `isActiveVersion` utility to include artifacts in the version comparison criteria.
- Updated the `VersionPanel` component to support artifacts in the agent state.
- Added new unit tests to validate artifacts matching scenarios and edge cases in the `isActiveVersion` function.

* Implement duplicate version detection in agent updates and enhance error handling

- Added `isDuplicateVersion` function to check for identical versions during agent updates, excluding certain fields.
- Updated `updateAgent` function to throw an error if a duplicate version is detected, with detailed error information.
- Enhanced the `updateAgentHandler` to return appropriate responses for duplicate version errors.
- Modified client-side error handling to display user-friendly messages for duplicate version scenarios.
- Added comprehensive unit tests to validate duplicate version detection and error handling across various update scenarios.

* Update version title localization to include version number across multiple languages

- Modified the `com_ui_agent_version_title` translation key to include a placeholder for the version number in various language files.
- Enhanced the `VersionItem` component to utilize the updated localization for displaying version titles dynamically.

* Enhance agent version handling and add revert functionality

- Updated the `isDuplicateVersion` function to improve version comparison logic, including special handling for `projectIds` and arrays of objects.
- Modified the `updateAgent` function to streamline version updates and removed unnecessary checks for test environments.
- Introduced a new `revertAgentVersion` function to allow reverting agents to specific versions, with detailed documentation.
- Enhanced unit tests to validate duplicate version detection and revert functionality, ensuring robust error handling and version management.

* fix CI issues

* cleanup

* Revert all non-English translations

* clean up tests
2025-05-20 15:03:13 -04:00
github-actions[bot]
28b76ce339 🌍 i18n: Update translation.json with latest translations (#7468)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-20 14:59:18 -04:00
Danny Avila
eb1668ff22 📂 refactor: Improve FileAttachment & File Form Deletion (#7471)
* refactor: optional attachment properties for `FileAttachment`

* refactor: update ActionButton to use localized text

* chore: localize text in DataTableFile, add missing translation, imports order, and linting

* chore: linting in DataTable

* fix: integrate Recoil state management for file deletion in DataTableFile

* fix: integrate Recoil state management for file deletion in DataTable

* fix: add temp_file_id to BatchFile type and update deleteFiles logic to properly remove files that are mapped to temp_file_id
2025-05-20 13:51:56 -04:00
Sebastien Bruel
e86842fd19 fix: Emojis rendering in SplitText Animation (#7460) 2025-05-20 09:26:58 -04:00
Danny Avila
af96666ff4 🖼️ chore: Linting & Transition Styling in UI Components (#7467)
* chore: linting

* 🔧 fix: Correctly parse dimensions for image width and height in OpenAIImageGen component

* style: overlay class for DialogImage component to improve visibility

* style: Update transition timing function for PixelCard component to rely on style props
2025-05-20 09:24:52 -04:00
arthurolivierfortin
59109cd2dd 🔬 fix: File Search Request Format (Azure Assistants API) (#7404)
* fix: The request format for file analysis with Azure OpenAI assistants

  The request format for file analysis with Azure OpenAI assistants differs from that of OpenAI. This fix updates the API to use attachments instead of file_ids. danny-avila#7379

* chore: ESLint Error

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-05-20 08:43:12 -04:00
Danny Avila
c8f5f5131e 🔧 fix: File Deletion for Azure Assistants API (#7466) 2025-05-20 08:37:39 -04:00
Amgad Hasan
8c0be0e2f0 🦙 chore: Add llama-4 to Vision Models List (#7433) 2025-05-19 19:43:44 -04:00
Ben Verhees
f8cb0cdcda 🔗 feat: Support Environment Variables in MCP URL Config (#7424) 2025-05-19 19:37:21 -04:00
René Honig
55d52d07f2 📃 fix: Ensure MCP Resources Pass Name and Description Fields to LLM (#7442) 2025-05-19 19:35:05 -04:00
Theo N. Truong
7ce782fec6 🎚️ feat: Custom Parameters (#7342)
* #

* - refactor: simplified getCustomConfig func

* #

* - feature: persist values for parameters with optionType of custom

* #

* - refactor: moved `Parameters/settings.ts` into `data-provider` so that both frontend and backend code can use it.

* - feature: loadCustomConfig can now parse and validate customParams property for `endpoints.custom` in `librechat.yaml`

* # fixed linter

* # removed .strict() in config.ts

* change: added packages/data-provider/src to SOURCE_DIRS for i18n check

* # removed unnecessary lodash imports

* # addressed PR comments
# fixed lint for updated files

* # better import for lodash (w/o relying on tree-shaking)
2025-05-19 19:33:25 -04:00
Marco Beretta
c79ee32006 🖼️ feat: Tool Call and Loading UI Refresh, Image Resize Config (#7086)
*  feat: Enhance Spinner component with customizable properties and improved animation

* 🔧 fix: Replace Loader with Spinner in RunCode component and update FilePreview to use Spinner for progress indication

*  feat: Refactor icons in CodeProgress and CancelledIcon components; enhance animation and styling in ExecuteCode and ProgressText components

*  feat: Refactor attachment handling in ExecuteCode component; replace individual attachment rendering with AttachmentGroup for improved structure

*  feat: Refactor dialog components for improved accessibility and styling; integrate Skeleton loading state in Image component

*  feat: Refactor ToolCall component to use ToolCallInfo for better structure; replace ToolPopover with AttachmentGroup; enhance ProgressText with error handling and improved UI elements

* 🔧 fix: Remove unnecessary whitespace in ProgressText

* 🔧 fix: Remove unnecessary margin from AgentFooter and AgentPanel components; clean up SidePanel imports

*  feat: Enhance ToolCall and ToolCallInfo components with improved styling; update translations and add warning text color to Tailwind config

* 🔧 fix: Update import statement for useLocalize in ToolCallInfo component; fix: chatform transition

*  feat: Refactor ToolCall and ToolCallInfo components for improved structure and styling; add optimized code block for better output display

*  feat: Implement OpenAI image generation component; add progress tracking and localization for user feedback

* 🔧 fix: Adjust base duration values for image generation; optimize timing for quality settings

* chore: remove unnecessary space

*  feat: Enhance OpenAI image generation with editing capabilities; update localization for progress feedback

*  feat: Add download functionality to images; enhance DialogImage component with download button

*  feat: Enhance image resizing functionality; support custom percentage and pixel dimensions in resizeImageBuffer
2025-05-19 19:23:11 -04:00
Danny Avila
739b0d3012 🛡️ chore: multer v2.0.0 for CVE-2025-47935 and CVE-2025-47944 (#7454)
* chore: bump multer to v2.0.0 to resolve CVE-2025-47935 and CVE-2025-47944

* chore: temp. remove helmet dependency to appease unused NPM package workflow
2025-05-19 19:22:43 -04:00
github-actions[bot]
9c9fe4e03a 📜 docs: Unreleased Changelog (#7434)
* action: update Unreleased changelog

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-05-19 10:28:18 -04:00
hofq
844bbbb162 📊 feat: Improve Helm Chart (#3638)
* Replaced Helm Charts with Blue Atlas Charts

* Fix Workflow

* improve docs

* update gitignore

* Update docs

* change values order, add hpa

* change tls example domain

* Default: Enable liveness and readiness

* chore: bump base chart

* apply requested changes

* add Release fix

* add: error handling

* chore: cleanup and testing

* fix: adjust Chart.yaml

---------

Co-authored-by: hofq <gregorspalme@protonmail.com>
Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
2025-05-17 15:52:16 -04:00
Danny Avila
26780bddf0 feat: Add Normalization for MCP Server Names (#7421) 2025-05-16 11:39:57 -04:00
Sebastien Bruel
353adceb0c 💽 fix: Exclude index page / from static cache settings (#7382)
* Disable default static caching for app's index page

* Update index.html related environment variables in `.env.example`

* Fix linting

* Update index.spec.js

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-05-16 10:18:52 -04:00
Danny Avila
a92ac23c44 🛡️ fix: Temporarily Remove CSP until Configurable (#7419) 2025-05-16 09:16:32 -04:00
Danny Avila
2a3bf259aa 🎨 style: revert email and password classes in LoginForm changed in #7377 2025-05-15 18:05:45 -04:00
Theo N. Truong
3152a1e536 🌘 fix: artifact of preview text is illegible in dark mode (#7405) 2025-05-15 17:50:09 -04:00
Danny Avila
2f4a03b581 🛡️ fix: Preset and Validation Logic for URL Query Params (#7407)
* chore(store/families): linting

* refactor: Update `createChatSearchParams` to use `tQueryParamsSchema` for allowed parameters and add `modelLabel` to schema

* refactor: Enhance `useQueryParams` to streamline parameter processing and improve submission handling

* chore: linting

* fix: Add `disableParams` option to conversation handling and related schemas to prevent search params from updating due to use of default preset

* fix: Update `createChatSearchParams` to correctly ignore `agent_id` when it matches `EPHEMERAL_AGENT_ID`

* chore: revert modelLabel addition to query params, as no longer necessary due to `disableParams`

* fix: Refine logic for `disableParams` to ensure correct handling of active preset comparison

* fix: Add `disableParams` option to `NewConversationParams` and update related hooks for preset handling

* fix: Refactor validation logic in `validateSettingDefinitions` to improve handling of `includeInput` and update conversation schema

* fix: Bump version of `librechat-data-provider` to 0.7.83
2025-05-15 17:46:48 -04:00
Ruben Talstra
7a91f6ca62 🔒 feat: Add Content Security Policy using Helmet middleware (#7377)
* 🔒 feat: Add Content Security Policy using Helmet middleware

* 🔒 feat: Set trust proxy and refine Content Security Policy directives

* 🎨 feat: add `copy-tex` to improve copying KaTeX (#7308)

When selecting equations and using copy paste, uses the correct latex code.

Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>

* 🔃 refactor: `AgentFooter` to conditionally render buttons based on `activePanel` (#7306)

* 🚀 feat: Add `Cloudflare Turnstile` support (#5987)

* 🚀 feat: Add @marsidev/react-turnstile dependency to package.json and package-lock.json

* 🚀 feat: Integrate Cloudflare Turnstile configuration support in AppService and add schema validation

* 🚀 feat: Implemented Cloudflare Turnstile integration in Login and Registration forms

* 🚀 feat: Enhance AppService tests with additional mocks and configuration setups

* 🚀 feat: Comment out outdated config version warning tests in AppService.spec.js

* 🚀 feat: Remove outdated warning tests and add new checks for environment variables and API health

* 🔧 test: Update AppService.spec.js to use expect.anything() for paths validation

* 🔧 test: Refactor AppService.spec.js to streamline mocks and enhance clarity

* 🔧 chore: removed not needed test

* Potential fix for code scanning alert no. 5638: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5629: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5642: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update turnstile.js

* Potential fix for code scanning alert no. 5634: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5646: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5647: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* 🔒 feat: Refactor Content Security Policy setup to use Helmet middleware with custom directives

* 🔒 feat: Enhance Content Security Policy to include Sandpack Bundler URL

* 🔒 feat: Update Content Security Policy and integrate Turnstile captcha support

---------

Co-authored-by: andresgit <9771158+andresgit@users.noreply.github.com>
Co-authored-by: matt burnett <mawburn@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-15 16:25:10 -04:00
Danny Avila
fe311df969 🔄 fix: Improve MCP Connection Cleanup (#7400)
* chore: linting for mcp related modules

* fix: update `isConnected` method to return a Promise and handle connection state asynchronously to properly handle/cleanup disconnected user connections
2025-05-15 12:17:17 -04:00
Ruben Talstra
535e7798b3 🚀 feat: Add Cloudflare Turnstile support (#5987)
* 🚀 feat: Add @marsidev/react-turnstile dependency to package.json and package-lock.json

* 🚀 feat: Integrate Cloudflare Turnstile configuration support in AppService and add schema validation

* 🚀 feat: Implemented Cloudflare Turnstile integration in Login and Registration forms

* 🚀 feat: Enhance AppService tests with additional mocks and configuration setups

* 🚀 feat: Comment out outdated config version warning tests in AppService.spec.js

* 🚀 feat: Remove outdated warning tests and add new checks for environment variables and API health

* 🔧 test: Update AppService.spec.js to use expect.anything() for paths validation

* 🔧 test: Refactor AppService.spec.js to streamline mocks and enhance clarity

* 🔧 chore: removed not needed test

* Potential fix for code scanning alert no. 5638: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5629: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5642: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update turnstile.js

* Potential fix for code scanning alert no. 5634: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5646: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5647: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-15 12:08:47 -04:00
matt burnett
621fa6e1aa 🔃 refactor: AgentFooter to conditionally render buttons based on activePanel (#7306) 2025-05-15 12:08:47 -04:00
andresgit
f6cc394eab 🎨 feat: add copy-tex to improve copying KaTeX (#7308)
When selecting equations and using copy paste, uses the correct latex code.

Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
2025-05-15 12:08:47 -04:00
Ruben Talstra
a85e853e12 Merge branch 'main' into feat/Multitenant-login-OIDC 2025-05-14 21:23:24 +02:00
github-actions[bot]
5b402a755e 🌍 i18n: Update translation.json with latest translations (#7375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-14 13:09:52 -04:00
Ruben Talstra
b0405be9ea 🌍 i18n: Add Danish and Czech and Catalan localization support (#7373)
* 🌍 i18n: Add Danish and Czech localization support

* 🌍 i18n: Correct Czech language code from 'sc-CZ' to 'cs-CZ'

* 🌍 i18n: Add Catalan localization support
2025-05-14 13:08:06 -04:00
github-actions[bot]
3f4dd08589 📜 docs: Unreleased Changelog (#7321)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-14 13:07:21 -04:00
Danny Avila
d5b399550e 📦 chore: Update API Package Dependencies (#7359)
* chore: temporarily remove @librechat/agents

* chore: bump @langchain/google-genai to v0.2.8

* chore: bump @langchain/google-vertexai to v0.2.8

* chore: bump @langchain/core to v0.3.55

* chore: bump @librechat/agents to v2.4.316

* chore: bump @librechat/agents to v2.4.317

* chore: update title for Unreleased Changelog PR to include documentation emoji

* chore: add workflow_dispatch trigger and update Pull Request title for changelog
2025-05-13 15:31:06 -04:00
Danny Avila
a5ff8253a4 🎏 feat: Add MCP support for Streamable HTTP Transport [2/2] (#7353)
- fixes type/packages issues not resolved in #7353
2025-05-13 13:26:37 -04:00
Ben Verhees
0b44142383 🎏 feat: Add MCP support for Streamable HTTP Transport (#7353) 2025-05-13 13:14:15 -04:00
matt burnett
502617db24 🔄 fix: update navigation logic in useFocusChatEffect to ensure correct search parameters are used (#7340) 2025-05-13 08:24:40 -04:00
Danny Avila
f2f285ca1e 🔑 fix: use apiKey instead of openAIApiKey in OpenAI-like Config (#7337) 2025-05-12 14:35:14 -04:00
Marco Beretta
6dd1b39886 💬 fix: update aria-label for accessibility in ConvoLink (#7320) 2025-05-12 08:12:51 -04:00
github-actions[bot]
5a43f87584 📜 docs: CHANGELOG for release v0.7.8 (#7290)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-12 08:10:58 -04:00
matt burnett
4af72aac9b feat: implement search parameter updates (#7151)
* feat: implement search parameter updates

* Update url params when values change

reset params on new chat

move logic to families.ts

revert unchanged files

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-05-09 13:03:33 -04:00
Ruben Talstra
da68cac76a Update .env.example 2025-03-21 21:12:39 +01:00
Ruben Talstra
069781b3a2 Update .env.example 2025-03-21 21:10:25 +01:00
Ruben Talstra
6333c45271 Update config.ts 2025-03-21 21:09:37 +01:00
Ruben Talstra
c14751cef5 Merge branch 'main' into feat/Multitenant-login-OIDC 2025-03-21 21:08:32 +01:00
Ruben Talstra
b9b0c03a63 Merge branch 'main' into feat/Multitenant-login-OIDC 2025-02-12 13:48:44 +01:00
Ruben Talstra
42f30a21f7 fixed failing test 2025-02-08 13:26:19 +01:00
Ruben Talstra
6577144554 started with Multi-Tenant OpenID.
TODO:
working code but needs some refactoring and cleaning up.
2025-02-08 13:12:07 +01:00
195 changed files with 10027 additions and 1361 deletions

View File

@@ -424,9 +424,11 @@ APPLE_PRIVATE_KEY_PATH=
APPLE_CALLBACK_URL=/oauth/apple/callback
# OpenID
OPENID_CLIENT_ID=
OPENID_CLIENT_SECRET=
OPENID_ISSUER=
OPENID_ENABLED=
OPENID_MULTI_TENANT=
#OPENID_CLIENT_ID=
#OPENID_CLIENT_SECRET=
#OPENID_ISSUER=
OPENID_SESSION_SECRET=
OPENID_SCOPE="openid profile email"
OPENID_CALLBACK_URL=/oauth/openid/callback
@@ -563,9 +565,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
# users always get the latest version. Customize #
# only if you understand caching implications. #
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
# INDEX_HTML_PRAGMA=no-cache
# INDEX_HTML_EXPIRES=0
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
# INDEX_PRAGMA=no-cache
# INDEX_EXPIRES=0
# no-cache: Forces validation with server before using cached version
# no-store: Prevents storing the response entirely

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
jobs:
generate-release-changelog-pr:
@@ -88,7 +89,7 @@ jobs:
base: main
branch: "changelog/${{ github.ref_name }}"
reviewers: danny-avila
title: "chore: update CHANGELOG for release ${{ github.ref_name }}"
title: "📜 docs: Changelog for release ${{ github.ref_name }}"
body: |
**Description**:
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.

View File

@@ -99,9 +99,9 @@ jobs:
branch: "changelog/unreleased-update"
sign-commits: true
commit-message: "action: update Unreleased changelog"
title: "action: update Unreleased changelog"
title: "📜 docs: Unreleased Changelog"
body: |
**Description**:
- This PR updates the Unreleased section in CHANGELOG.md.
- It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}),
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.

View File

@@ -29,5 +29,8 @@ jobs:
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -22,7 +22,7 @@ jobs:
# Define paths
I18N_FILE="client/src/locales/en/translation.json"
SOURCE_DIRS=("client/src" "api")
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
# Check if translation file exists
if [[ ! -f "$I18N_FILE" ]]; then

7
.gitignore vendored
View File

@@ -113,4 +113,11 @@ uploads/
# owner
release/
# Helm
helm/librechat/Chart.lock
helm/**/charts/
helm/**/.values.yaml
!/client/src/@types/i18next.d.ts

View File

@@ -4,12 +4,63 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### ✨ New Features
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
### 🌍 Internationalization
- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
### 🔧 Fixes
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
---
## [v0.7.8] -
Changes from v0.7.8-rc1 to v0.7.8.
### ✨ New Features
- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
### 🔧 Fixes
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
### ⚙️ Other Changes
@@ -18,9 +69,17 @@ All notable changes to this project will be documented in this file.
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
[See full release details][release-v0.7.8]
[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
---
## [v0.7.8-rc1] -

View File

@@ -30,7 +30,7 @@ const DEFAULT_IMAGE_EDIT_DESCRIPTION =
When to use \`image_edit_oai\`:
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
- Any current or existing images are to be used as visual guides.
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.

View File

@@ -21,7 +21,19 @@ const Agent = mongoose.model('agent', agentSchema);
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
return (await Agent.create(agentData)).toObject();
const { versions, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
versions: [
{
...versionData,
createdAt: timestamp,
updatedAt: timestamp,
},
],
};
return (await Agent.create(initialAgentData)).toObject();
};
/**
@@ -103,6 +115,8 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
return null;
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.author.toString() === req.user.id) {
return agent;
}
@@ -127,18 +141,146 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
};
/**
* Check if a version already exists in the versions array, excluding timestamp and author fields
* @param {Object} updateData - The update data to compare
* @param {Array} versions - The existing versions array
* @returns {Object|null} - The matching version if found, null otherwise
*/
const isDuplicateVersion = (updateData, currentData, versions) => {
if (!versions || versions.length === 0) {
return null;
}
const excludeFields = [
'_id',
'id',
'createdAt',
'updatedAt',
'author',
'created_at',
'updated_at',
'__v',
'agent_ids',
'versions',
];
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0) {
return null;
}
const wouldBeVersion = { ...currentData, ...directUpdates };
const lastVersion = versions[versions.length - 1];
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
let isMatch = true;
for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
continue;
}
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
return isMatch ? lastVersion : null;
};
/**
* Update an agent with new data without overwriting existing
* properties, or create a new agent if it doesn't exist.
* When an agent is updated, a copy of the current state will be saved to the versions array.
*
* @param {Object} searchParameter - The search parameters to find the agent to update.
* @param {string} searchParameter.id - The ID of the agent to update.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
* @throws {Error} If the update would create a duplicate version
*/
const updateAgent = async (searchParameter, updateData) => {
const options = { new: true, upsert: false };
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
if (duplicateVersion) {
const error = new Error(
'Duplicate version: This would create a version identical to an existing one',
);
error.statusCode = 409;
error.details = {
duplicateVersion,
versionIndex: versions.findIndex(
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
),
};
throw error;
}
}
updateData.$push = {
...($push || {}),
versions: {
...versionData,
...directUpdates,
updatedAt: new Date(),
},
};
}
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
};
@@ -358,6 +500,38 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
return await getAgent({ id: agentId });
};
/**
* Reverts an agent to a specific version in its version history.
* @param {Object} searchParameter - The search parameters to find the agent to revert.
* @param {string} searchParameter.id - The ID of the agent to revert.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {number} versionIndex - The index of the version to revert to in the versions array.
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
* @throws {Error} If the agent is not found or the specified version does not exist.
*/
const revertAgentVersion = async (searchParameter, versionIndex) => {
const agent = await Agent.findOne(searchParameter);
if (!agent) {
throw new Error('Agent not found');
}
if (!agent.versions || !agent.versions[versionIndex]) {
throw new Error(`Version ${versionIndex} not found`);
}
const revertToVersion = agent.versions[versionIndex];
const updateData = {
...revertToVersion,
};
delete updateData._id;
delete updateData.id;
delete updateData.versions;
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
};
module.exports = {
Agent,
getAgent,
@@ -369,4 +543,5 @@ module.exports = {
updateAgentProjects,
addAgentResourceFile,
removeAgentResourceFiles,
revertAgentVersion,
};

View File

@@ -1,7 +1,25 @@
const originalEnv = {
CREDS_KEY: process.env.CREDS_KEY,
CREDS_IV: process.env.CREDS_IV,
};
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
const {
Agent,
addAgentResourceFile,
removeAgentResourceFiles,
createAgent,
updateAgent,
getAgent,
deleteAgent,
getListAgents,
updateAgentProjects,
} = require('./Agent');
describe('Agent Resource File Operations', () => {
let mongoServer;
@@ -15,6 +33,8 @@ describe('Agent Resource File Operations', () => {
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
process.env.CREDS_IV = originalEnv.CREDS_IV;
});
beforeEach(async () => {
@@ -332,3 +352,537 @@ describe('Agent Resource File Operations', () => {
expect(finalFileIds).toHaveLength(0);
});
});
describe('Agent CRUD Operations', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should create and get an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const newAgent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Test description',
});
expect(newAgent).toBeDefined();
expect(newAgent.id).toBe(agentId);
expect(newAgent.name).toBe('Test Agent');
const retrievedAgent = await getAgent({ id: agentId });
expect(retrievedAgent).toBeDefined();
expect(retrievedAgent.id).toBe(agentId);
expect(retrievedAgent.name).toBe('Test Agent');
expect(retrievedAgent.description).toBe('Test description');
});
test('should delete an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agentBeforeDelete = await getAgent({ id: agentId });
expect(agentBeforeDelete).toBeDefined();
await deleteAgent({ id: agentId });
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
});
test('should list agents by author', async () => {
const authorId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const agentIds = [];
for (let i = 0; i < 5; i++) {
const id = `agent_${uuidv4()}`;
agentIds.push(id);
await createAgent({
id,
name: `Agent ${i}`,
provider: 'test',
model: 'test-model',
author: authorId,
});
}
for (let i = 0; i < 3; i++) {
await createAgent({
id: `other_agent_${uuidv4()}`,
name: `Other Agent ${i}`,
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
}
const result = await getListAgents({ author: authorId.toString() });
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveLength(5);
expect(result.has_more).toBe(true);
for (const agent of result.data) {
expect(agent.author).toBe(authorId.toString());
}
});
test('should update agent projects', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const projectId3 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Project Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
);
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
const updatedAgent = await getAgent({ id: agentId });
expect(updatedAgent.projectIds).toHaveLength(2);
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
await updateAgent({ id: agentId }, { projectIds: [] });
const emptyProjectsAgent = await getAgent({ id: agentId });
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
const nonExistentId = `agent_${uuidv4()}`;
await expect(
updateAgentProjects({
id: nonExistentId,
projectIds: [projectId1],
}),
).rejects.toThrow();
});
test('should handle ephemeral agent loading', async () => {
const agentId = 'ephemeral_test';
const endpoint = 'openai';
const originalModule = jest.requireActual('librechat-data-provider');
const mockDataProvider = {
...originalModule,
Constants: {
...originalModule.Constants,
EPHEMERAL_AGENT_ID: 'ephemeral_test',
},
};
jest.doMock('librechat-data-provider', () => mockDataProvider);
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'This is a test instruction',
ephemeralAgent: {
execute_code: true,
mcp: ['server1', 'server2'],
},
},
app: {
locals: {
availableTools: {
tool__server1: {},
tool__server2: {},
another_tool: {},
},
},
},
};
const params = {
req: mockReq,
agent_id: agentId,
endpoint,
model_parameters: {
model: 'gpt-4',
temperature: 0.7,
},
};
expect(agentId).toBeDefined();
expect(endpoint).toBeDefined();
jest.dontMock('librechat-data-provider');
});
test('should handle loadAgent functionality and errors', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Load Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1', 'tool2'],
});
const agent = await getAgent({ id: agentId });
expect(agent).toBeDefined();
expect(agent.id).toBe(agentId);
expect(agent.name).toBe('Test Load Agent');
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
const loadedAgent = await mockLoadAgent();
expect(loadedAgent).toBeDefined();
expect(loadedAgent.id).toBe(agentId);
const nonExistentId = `agent_${uuidv4()}`;
const nonExistentAgent = await getAgent({ id: nonExistentId });
expect(nonExistentAgent).toBeNull();
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
});
});
describe('Agent Version History', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should create an agent with a single entry in versions array', async () => {
const agentId = `agent_${uuidv4()}`;
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
expect(agent.versions).toBeDefined();
expect(Array.isArray(agent.versions)).toBe(true);
expect(agent.versions).toHaveLength(1);
expect(agent.versions[0].name).toBe('Test Agent');
expect(agent.versions[0].provider).toBe('test');
expect(agent.versions[0].model).toBe('test-model');
});
test('should accumulate version history across multiple updates', async () => {
const agentId = `agent_${uuidv4()}`;
const author = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'First Name',
provider: 'test',
model: 'test-model',
author,
description: 'First description',
});
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
expect(finalAgent.versions).toBeDefined();
expect(Array.isArray(finalAgent.versions)).toBe(true);
expect(finalAgent.versions).toHaveLength(4);
expect(finalAgent.versions[0].name).toBe('First Name');
expect(finalAgent.versions[0].description).toBe('First description');
expect(finalAgent.versions[0].model).toBe('test-model');
expect(finalAgent.versions[1].name).toBe('Second Name');
expect(finalAgent.versions[1].description).toBe('Second description');
expect(finalAgent.versions[1].model).toBe('test-model');
expect(finalAgent.versions[2].name).toBe('Third Name');
expect(finalAgent.versions[2].description).toBe('Second description');
expect(finalAgent.versions[2].model).toBe('new-model');
expect(finalAgent.versions[3].name).toBe('Third Name');
expect(finalAgent.versions[3].description).toBe('Final description');
expect(finalAgent.versions[3].model).toBe('new-model');
expect(finalAgent.name).toBe('Third Name');
expect(finalAgent.description).toBe('Final description');
expect(finalAgent.model).toBe('new-model');
});
test('should not include metadata fields in version history', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.versions[0]._id).toBeUndefined();
expect(updatedAgent.versions[0].__v).toBeUndefined();
expect(updatedAgent.versions[0].name).toBe('Test Agent');
expect(updatedAgent.versions[0].author).toBeDefined();
expect(updatedAgent.versions[1]._id).toBeUndefined();
expect(updatedAgent.versions[1].__v).toBeUndefined();
});
test('should not recursively include previous versions', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
expect(finalAgent.versions).toHaveLength(4);
finalAgent.versions.forEach((version) => {
expect(version.versions).toBeUndefined();
});
});
test('should handle MongoDB operators and field updates correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'MongoDB Operator Test',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1'],
});
await updateAgent(
{ id: agentId },
{
description: 'Updated description',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId },
},
);
const firstUpdate = await getAgent({ id: agentId });
expect(firstUpdate.description).toBe('Updated description');
expect(firstUpdate.tools).toContain('tool1');
expect(firstUpdate.tools).toContain('tool2');
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(firstUpdate.versions).toHaveLength(2);
await updateAgent(
{ id: agentId },
{
tools: ['tool2', 'tool3'],
},
);
const secondUpdate = await getAgent({ id: agentId });
expect(secondUpdate.tools).toHaveLength(2);
expect(secondUpdate.tools).toContain('tool2');
expect(secondUpdate.tools).toContain('tool3');
expect(secondUpdate.tools).not.toContain('tool1');
expect(secondUpdate.versions).toHaveLength(3);
await updateAgent(
{ id: agentId },
{
$push: { tools: 'tool3' },
},
);
const thirdUpdate = await getAgent({ id: agentId });
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
expect(toolCount).toBe(2);
expect(thirdUpdate.versions).toHaveLength(4);
});
test('should handle parameter objects correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Parameters Test',
provider: 'test',
model: 'test-model',
author: authorId,
model_parameters: { temperature: 0.7 },
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ model_parameters: { temperature: 0.8 } },
);
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
await updateAgent(
{ id: agentId },
{
model_parameters: {
temperature: 0.8,
max_tokens: 1000,
},
},
);
const complexAgent = await getAgent({ id: agentId });
expect(complexAgent.versions).toHaveLength(3);
expect(complexAgent.model_parameters.temperature).toBe(0.8);
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
await updateAgent({ id: agentId }, { model_parameters: {} });
const emptyParamsAgent = await getAgent({ id: agentId });
expect(emptyParamsAgent.versions).toHaveLength(4);
expect(emptyParamsAgent.model_parameters).toEqual({});
});
test('should detect duplicate versions and reject updates', async () => {
const originalConsoleError = console.error;
console.error = jest.fn();
try {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const testCases = [
{
name: 'simple field update',
initial: {
name: 'Test Agent',
description: 'Initial description',
},
update: { name: 'Updated Name' },
duplicate: { name: 'Updated Name' },
},
{
name: 'object field update',
initial: {
model_parameters: { temperature: 0.7 },
},
update: { model_parameters: { temperature: 0.8 } },
duplicate: { model_parameters: { temperature: 0.8 } },
},
{
name: 'array field update',
initial: {
tools: ['tool1', 'tool2'],
},
update: { tools: ['tool2', 'tool3'] },
duplicate: { tools: ['tool2', 'tool3'] },
},
{
name: 'projectIds update',
initial: {
projectIds: [projectId1],
},
update: { projectIds: [projectId1, projectId2] },
duplicate: { projectIds: [projectId2, projectId1] },
},
];
for (const testCase of testCases) {
const testAgentId = `agent_${uuidv4()}`;
await createAgent({
id: testAgentId,
provider: 'test',
model: 'test-model',
author: authorId,
...testCase.initial,
});
await updateAgent({ id: testAgentId }, testCase.update);
let error;
try {
await updateAgent({ id: testAgentId }, testCase.duplicate);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain('Duplicate version');
expect(error.statusCode).toBe(409);
expect(error.details).toBeDefined();
expect(error.details.duplicateVersion).toBeDefined();
const agent = await getAgent({ id: testAgentId });
expect(agent.versions).toHaveLength(2);
}
} finally {
console.error = originalConsoleError;
}
});
});

View File

@@ -44,11 +44,11 @@
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.42",
"@langchain/core": "^0.3.51",
"@langchain/google-genai": "^0.2.5",
"@langchain/google-vertexai": "^0.2.5",
"@langchain/core": "^0.3.55",
"@langchain/google-genai": "^0.2.8",
"@langchain/google-vertexai": "^0.2.8",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.314",
"@librechat/agents": "^2.4.317",
"@librechat/data-schemas": "*",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
@@ -86,7 +86,7 @@
"mime": "^3.0.0",
"module-alias": "^2.2.3",
"mongoose": "^8.12.1",
"multer": "^1.4.5-lts.1",
"multer": "^2.0.0",
"nanoid": "^3.3.7",
"nodemailer": "^6.9.15",
"ollama": "^0.5.0",

View File

@@ -23,6 +23,7 @@ const { updateAction, getActions } = require('~/models/Action');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { deleteFileByFilter } = require('~/models/File');
const { revertAgentVersion } = require('~/models/Agent');
const { logger } = require('~/config');
const systemTools = {
@@ -104,6 +105,8 @@ const getAgentHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const originalUrl = agent.avatar.filepath;
agent.avatar.filepath = await refreshS3Url(agent.avatar);
@@ -127,6 +130,7 @@ const getAgentHandler = async (req, res) => {
author: agent.author,
projectIds: agent.projectIds,
isCollaborative: agent.isCollaborative,
version: agent.version,
});
}
return res.status(200).json(agent);
@@ -187,6 +191,14 @@ const updateAgentHandler = async (req, res) => {
return res.json(updatedAgent);
} catch (error) {
logger.error('[/Agents/:id] Error updating Agent', error);
if (error.statusCode === 409) {
return res.status(409).json({
error: error.message,
details: error.details,
});
}
res.status(500).json({ error: error.message });
}
};
@@ -411,6 +423,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
}
};
/**
* Reverts an agent to a previous version from its version history.
* @route PATCH /agents/:id/revert
* @param {object} req - Express Request object
* @param {object} req.params - Request parameters
* @param {string} req.params.id - The ID of the agent to revert
* @param {object} req.body - Request body
* @param {number} req.body.version_index - The index of the version to revert to
* @param {object} req.user - Authenticated user information
* @param {string} req.user.id - User ID
* @param {string} req.user.role - User role
* @param {ServerResponse} res - Express Response object
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
* @throws {Error} 400 - If version_index is missing
* @throws {Error} 403 - If user doesn't have permission to modify the agent
* @throws {Error} 404 - If agent not found
* @throws {Error} 500 - If there's an internal server error during the reversion process
*/
const revertAgentVersionHandler = async (req, res) => {
try {
const { id } = req.params;
const { version_index } = req.body;
if (version_index === undefined) {
return res.status(400).json({ error: 'version_index is required' });
}
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const isAuthor = existingAgent.author.toString() === req.user.id;
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
const updatedAgent = await revertAgentVersion({ id }, version_index);
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
return res.json(updatedAgent);
} catch (error) {
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
res.status(500).json({ error: error.message });
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@@ -419,4 +491,5 @@ module.exports = {
deleteAgent: deleteAgentHandler,
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
};

View File

@@ -326,8 +326,15 @@ const chatV1 = async (req, res) => {
file_ids = files.map(({ file_id }) => file_id);
if (file_ids.length || thread_file_ids.length) {
userMessage.file_ids = file_ids;
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
if (endpoint === EModelEndpoint.azureAssistants) {
userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
file_id,
tools: [{ type: 'file_search' }],
}));
} else {
userMessage.file_ids = Array.from(attachedFileIds);
}
}
};

View File

@@ -24,10 +24,13 @@ const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
const port = Number(PORT) || 3080;
// Allow PORT=0 to be used for automatic free port assignment
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
const host = HOST || 'localhost';
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
const app = express();
const startServer = async () => {
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
@@ -36,8 +39,9 @@ const startServer = async () => {
logger.info('Connected to MongoDB');
await indexSync();
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
await AppService(app);
const indexPath = path.join(app.locals.paths.dist, 'index.html');
@@ -49,23 +53,24 @@ const startServer = async () => {
app.use(noIndex);
app.use(errorController);
app.use(express.json({ limit: '3mb' }));
app.use(mongoSanitize());
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(staticCache(app.locals.paths.dist));
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
app.set('trust proxy', trusted_proxy);
app.use(mongoSanitize());
app.use(cors());
app.use(cookieParser());
if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression());
} else {
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
}
// Serve static assets with aggressive caching
app.use(staticCache(app.locals.paths.dist));
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) {
console.warn(
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
);
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
}
/* OAUTH */
@@ -128,7 +133,7 @@ const startServer = async () => {
});
app.listen(port, host, () => {
if (host == '0.0.0.0') {
if (host === '0.0.0.0') {
logger.info(
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
);
@@ -176,3 +181,6 @@ process.on('uncaughtException', (err) => {
process.exit(1);
});
// export app for easier testing purposes
module.exports = app;

78
api/server/index.spec.js Normal file
View File

@@ -0,0 +1,78 @@
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
describe('Server Configuration', () => {
// Increase the default timeout to allow for Mongo cleanup
jest.setTimeout(30_000);
let mongoServer;
let app;
/** Mocked fs.readFileSync for index.html */
const originalReadFileSync = fs.readFileSync;
beforeAll(() => {
fs.readFileSync = function (filepath, options) {
if (filepath.includes('index.html')) {
return '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
}
return originalReadFileSync(filepath, options);
};
});
afterAll(() => {
// Restore original fs.readFileSync
fs.readFileSync = originalReadFileSync;
});
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0'; // Use a random available port
app = require('~/server');
// Wait for the app to be healthy
await healthCheckPoll(app);
});
afterAll(async () => {
await mongoServer.stop();
await mongoose.disconnect();
});
it('should return OK for /health', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.text).toBe('OK');
});
it('should not cache index page', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
expect(response.headers['pragma']).toBe('no-cache');
expect(response.headers['expires']).toBe('0');
});
});
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
async function healthCheckPoll(app, retries = 0) {
const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
try {
const response = await request(app).get('/health');
if (response.status === 200) {
return; // App is healthy
}
} catch (error) {
// Ignore connection errors during polling
}
if (retries < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 30));
await healthCheckPoll(app, retries + 1);
} else {
throw new Error('App did not become healthy within 10 seconds.');
}
}

View File

@@ -78,6 +78,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
*/
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
/**
* Reverts an agent to a previous version.
* @route POST /agents/:id/revert
* @param {string} req.params.id - Agent identifier.
* @param {number} req.body.version_index - Index of the version to revert to.
* @returns {Agent} 200 - success response - application/json
*/
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
/**
* Returns a list of agents.
* @route GET /agents

View File

@@ -52,10 +52,9 @@ router.get('/', async function (req, res) {
!!process.env.APPLE_KEY_ID &&
!!process.env.APPLE_PRIVATE_KEY_PATH,
openidLoginEnabled:
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&
!!process.env.OPENID_ISSUER &&
!!process.env.OPENID_ENABLED &&
!!process.env.OPENID_SESSION_SECRET,
openidMultiTenantEnabled: !!process.env.OPENID_MULTI_TENANT,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL,
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
@@ -75,6 +74,7 @@ router.get('/', async function (req, res) {
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: req.app.locals.interfaceConfig,
turnstile: req.app.locals.turnstileConfig,
modelSpecs: req.app.locals.modelSpecs,
balance: req.app.locals.balance,
sharedLinksEnabled,

View File

@@ -121,6 +121,14 @@ router.delete('/', async (req, res) => {
await processDeleteRequest({ req, files: assistantFiles });
res.status(200).json({ message: 'File associations removed successfully from assistant' });
return;
} else if (
req.body.assistant_id &&
req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
) {
await processDeleteRequest({ req, files: req.body.files });
return res
.status(200)
.json({ message: 'File associations removed successfully from Azure Assistant' });
}
await processDeleteRequest({ req, files: dbFiles });

View File

@@ -10,6 +10,7 @@ const {
} = require('~/server/middleware');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const { chooseOpenIdStrategy } = require('~/server/utils/openidHelper');
const router = express.Router();
@@ -94,20 +95,32 @@ router.get(
/**
* OpenID Routes
*/
router.get(
'/openid',
passport.authenticate('openid', {
session: false,
}),
);
router.get('/openid', async (req, res, next) => {
try {
const strategy = await chooseOpenIdStrategy(req);
console.log('OpenID login using strategy:', strategy);
passport.authenticate(strategy, {
session: false,
})(req, res, next);
} catch (err) {
next(err);
}
});
router.get(
'/openid/callback',
passport.authenticate('openid', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
}),
async (req, res, next) => {
try {
const strategy = await chooseOpenIdStrategy(req);
passport.authenticate(strategy, {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
})(req, res, next);
} catch (err) {
next(err);
}
},
setBalanceConfig,
oauthHandler,
);

View File

@@ -12,6 +12,7 @@ const { initializeFirebase } = require('./Files/Firebase/initialize');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
const { loadTurnstileConfig } = require('./start/turnstile');
const { azureConfigSetup } = require('./start/azureOpenAI');
const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize');
@@ -23,7 +24,6 @@ const { getMCPManager } = require('~/config');
const paths = require('~/config/paths');
/**
*
* Loads custom config and initializes app-wide variables.
* @function AppService
* @param {Express.Application} app - The Express application object.
@@ -74,6 +74,7 @@ const AppService = async (app) => {
const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
const turnstileConfig = loadTurnstileConfig(config, configDefaults);
const defaultLocals = {
ocr,
@@ -85,6 +86,7 @@ const AppService = async (app) => {
availableTools,
imageOutputType,
interfaceConfig,
turnstileConfig,
balance,
};

View File

@@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({
},
}),
}));
jest.mock('./start/turnstile', () => ({
loadTurnstileConfig: jest.fn(() => ({
siteKey: 'default-site-key',
options: {},
})),
}));
const azureGroups = [
{
@@ -86,6 +92,10 @@ const azureGroups = [
describe('AppService', () => {
let app;
const mockedTurnstileConfig = {
siteKey: 'default-site-key',
options: {},
};
beforeEach(() => {
app = { locals: {} };
@@ -107,6 +117,7 @@ describe('AppService', () => {
sidePanel: true,
presets: true,
}),
turnstileConfig: mockedTurnstileConfig,
modelSpecs: undefined,
availableTools: {
ExampleTool: {

View File

@@ -10,17 +10,7 @@ const getLogStores = require('~/cache/getLogStores');
* */
async function getCustomConfig() {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG);
if (!customConfig) {
customConfig = await loadCustomConfig();
}
if (!customConfig) {
return null;
}
return customConfig;
return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig());
}
/**

View File

@@ -29,7 +29,14 @@ async function loadConfigEndpoints(req) {
for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i];
const { baseURL, apiKey, name: configName, iconURL, modelDisplayLabel } = endpoint;
const {
baseURL,
apiKey,
name: configName,
iconURL,
modelDisplayLabel,
customParams,
} = endpoint;
const name = normalizeEndpointName(configName);
const resolvedApiKey = extractEnvVariable(apiKey);
@@ -41,6 +48,7 @@ async function loadConfigEndpoints(req) {
userProvideURL: isUserProvided(resolvedBaseURL),
modelDisplayLabel,
iconURL,
customParams,
};
}
}

View File

@@ -1,10 +1,18 @@
const path = require('path');
const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider');
const {
CacheKeys,
configSchema,
EImageOutputType,
validateSettingDefinitions,
agentParamSettings,
paramSettings,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
const axios = require('axios');
const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
@@ -105,6 +113,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
logger.debug('Custom config:', customConfig);
}
(customConfig.endpoints?.custom ?? [])
.filter((endpoint) => endpoint.customParams)
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
if (customConfig.cache) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
@@ -117,4 +129,52 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
return customConfig;
}
// Validate and fill out missing values for custom parameters
function parseCustomParams(endpointName, customParams) {
const paramEndpoint = customParams.defaultParamsEndpoint;
customParams.paramDefinitions = customParams.paramDefinitions || [];
// Checks if `defaultParamsEndpoint` is a key in `paramSettings`.
const validEndpoints = new Set([
...Object.keys(paramSettings),
...Object.keys(agentParamSettings),
]);
if (!validEndpoints.has(paramEndpoint)) {
throw new Error(
`defaultParamsEndpoint of "${endpointName}" endpoint is invalid. ` +
`Valid options are ${Array.from(validEndpoints).join(', ')}`,
);
}
// creates default param maps
const regularParams = paramSettings[paramEndpoint] ?? [];
const agentParams = agentParamSettings[paramEndpoint] ?? [];
const defaultParams = regularParams.concat(agentParams);
const defaultParamsMap = keyBy(defaultParams, 'key');
// TODO: Remove this check once we support new parameters not part of default parameters.
// Checks if every key in `paramDefinitions` is valid.
const validKeys = new Set(Object.keys(defaultParamsMap));
const paramKeys = customParams.paramDefinitions.map((param) => param.key);
if (paramKeys.some((key) => !validKeys.has(key))) {
throw new Error(
`paramDefinitions of "${endpointName}" endpoint contains invalid key(s). ` +
`Valid parameter keys are ${Array.from(validKeys).join(', ')}`,
);
}
// Fill out missing values for custom param definitions
customParams.paramDefinitions = customParams.paramDefinitions.map((param) => {
return { ...defaultParamsMap[param.key], ...param, optionType: 'custom' };
});
try {
validateSettingDefinitions(customParams.paramDefinitions);
} catch (e) {
throw new Error(
`Custom parameter definitions for "${endpointName}" endpoint is malformed: ${e.message}`,
);
}
}
module.exports = loadCustomConfig;

View File

@@ -1,6 +1,34 @@
jest.mock('axios');
jest.mock('~/cache/getLogStores');
jest.mock('~/utils/loadYaml');
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
paramSettings: { foo: {}, bar: {}, custom: {} },
agentParamSettings: {
custom: [],
google: [
{
key: 'pressure',
type: 'string',
component: 'input',
},
{
key: 'temperature',
type: 'number',
component: 'slider',
default: 0.5,
range: {
min: 0,
max: 2,
step: 0.01,
},
},
],
},
};
});
const axios = require('axios');
const loadCustomConfig = require('./loadCustomConfig');
@@ -150,4 +178,126 @@ describe('loadCustomConfig', () => {
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2));
expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig);
});
describe('parseCustomParams', () => {
const mockConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'Google',
apiKey: 'user_provided',
customParams: {},
},
],
},
};
async function loadCustomParams(customParams) {
mockConfig.endpoints.custom[0].customParams = customParams;
loadYaml.mockReturnValue(mockConfig);
return await loadCustomConfig();
}
beforeEach(() => {
jest.resetAllMocks();
process.env.CONFIG_PATH = 'validConfig.yaml';
});
it('returns no error when customParams is undefined', async () => {
const result = await loadCustomParams(undefined);
expect(result).toEqual(mockConfig);
});
it('returns no error when customParams is valid', async () => {
const result = await loadCustomParams({
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
default: 0.5,
},
],
});
expect(result).toEqual(mockConfig);
});
it('throws an error when paramDefinitions contain unsupported keys', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.5 },
{ key: 'unsupportedKey', range: 0.5 },
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'paramDefinitions of "Google" endpoint contains invalid key(s). Valid parameter keys are pressure, temperature',
);
});
it('throws an error when paramDefinitions is malformed', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
type: 'noomba',
component: 'inpoot',
optionType: 'custom',
},
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
/Custom parameter definitions for "Google" endpoint is malformed:/,
);
});
it('throws an error when defaultParamsEndpoint is not provided', async () => {
const malformedCustomParams = { defaultParamsEndpoint: undefined };
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google',
);
});
it('fills the paramDefinitions with missing values', async () => {
const customParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.7, range: { min: 0.1, max: 0.9, step: 0.1 } },
{ key: 'pressure', component: 'textarea' },
],
};
const parsedConfig = await loadCustomParams(customParams);
const paramDefinitions = parsedConfig.endpoints.custom[0].customParams.paramDefinitions;
expect(paramDefinitions).toEqual([
{
columnSpan: 1,
component: 'slider',
default: 0.7, // overridden
includeInput: true,
key: 'temperature',
label: 'temperature',
optionType: 'custom',
range: {
// overridden
max: 0.9,
min: 0.1,
step: 0.1,
},
type: 'number',
},
{
columnSpan: 1,
component: 'textarea', // overridden
key: 'pressure',
label: 'pressure',
optionType: 'custom',
placeholder: '',
type: 'string',
},
]);
});
});
});

View File

@@ -105,6 +105,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
headers: resolvedHeaders,
addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams,
titleConvo: endpointConfig.titleConvo,
titleModel: endpointConfig.titleModel,
forcePrompt: endpointConfig.forcePrompt,

View File

@@ -136,7 +136,7 @@ function getLLMConfig(apiKey, options = {}, endpoint = null) {
Object.assign(llmConfig, azure);
llmConfig.model = llmConfig.azureOpenAIApiDeploymentName;
} else {
llmConfig.openAIApiKey = apiKey;
llmConfig.apiKey = apiKey;
// Object.assign(llmConfig, {
// configuration: { apiKey },
// });

View File

@@ -54,7 +54,7 @@ async function deleteOpenAIFile(req, file, openai) {
throw new Error('OpenAI returned `false` for deleted status');
}
logger.debug(
`[deleteOpenAIFile] User ${req.user.id} successfully deleted ${file.file_id} from OpenAI`,
`[deleteOpenAIFile] User ${req.user.id} successfully deleted file "${file.file_id}" from OpenAI`,
);
} catch (error) {
logger.error('[deleteOpenAIFile] Error deleting file from OpenAI: ' + error.message);

View File

@@ -5,9 +5,10 @@ const { EModelEndpoint } = require('librechat-data-provider');
* Resizes an image from a given buffer based on the specified resolution.
*
* @param {Buffer} inputBuffer - The buffer of the image to be resized.
* @param {'low' | 'high'} resolution - The resolution to resize the image to.
* @param {'low' | 'high' | {percentage?: number, px?: number}} resolution - The resolution to resize the image to.
* 'low' for a maximum of 512x512 resolution,
* 'high' for a maximum of 768x2000 resolution.
* 'high' for a maximum of 768x2000 resolution,
* or a custom object with percentage or px values.
* @param {EModelEndpoint} endpoint - Identifier for specific endpoint handling
* @returns {Promise<{buffer: Buffer, width: number, height: number}>} An object containing the resized image buffer and its dimensions.
* @throws Will throw an error if the resolution parameter is invalid.
@@ -17,10 +18,32 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
const maxShortSideHighRes = 768;
const maxLongSideHighRes = endpoint === EModelEndpoint.anthropic ? 1568 : 2000;
let customPercent, customPx;
if (resolution && typeof resolution === 'object') {
if (typeof resolution.percentage === 'number') {
customPercent = resolution.percentage;
} else if (typeof resolution.px === 'number') {
customPx = resolution.px;
}
}
let newWidth, newHeight;
let resizeOptions = { fit: 'inside', withoutEnlargement: true };
if (resolution === 'low') {
if (customPercent != null || customPx != null) {
// percentage-based resize
const metadata = await sharp(inputBuffer).metadata();
if (customPercent != null) {
newWidth = Math.round(metadata.width * (customPercent / 100));
newHeight = Math.round(metadata.height * (customPercent / 100));
} else {
// pixel max on both sides
newWidth = Math.min(metadata.width, customPx);
newHeight = Math.min(metadata.height, customPx);
}
resizeOptions.width = newWidth;
resizeOptions.height = newHeight;
} else if (resolution === 'low') {
resizeOptions.width = maxLowRes;
resizeOptions.height = maxLowRes;
} else if (resolution === 'high') {

View File

@@ -137,11 +137,13 @@ const processDeleteRequest = async ({ req, files }) => {
/** @type {Record<string, OpenAI | undefined>} */
const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined };
const initializeClients = async () => {
const openAIClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.assistants,
});
client[FileSources.openai] = openAIClient.openai;
if (req.app.locals[EModelEndpoint.assistants]) {
const openAIClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.assistants,
});
client[FileSources.openai] = openAIClient.openai;
}
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
return;
@@ -693,7 +695,7 @@ const processOpenAIFile = async ({
const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => {
const currentDate = new Date();
const formattedDate = currentDate.toISOString();
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`);
const file = {
..._file,
usage: 1,
@@ -838,8 +840,9 @@ function base64ToBuffer(base64String) {
async function saveBase64Image(
url,
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
) {
const effectiveResolution = resolution ?? req.app.locals.fileConfig?.imageGeneration ?? 'high';
const file_id = _file_id ?? v4();
let filename = `${file_id}-${_filename}`;
const { buffer: inputBuffer, type } = base64ToBuffer(url);
@@ -852,7 +855,7 @@ async function saveBase64Image(
}
}
const image = await resizeImageBuffer(inputBuffer, resolution, endpoint);
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint);
const source = req.app.locals.fileStrategy;
const { saveBuffer } = getStrategyFunctions(source);
const filepath = await saveBuffer({

View File

@@ -1,5 +1,6 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { normalizeServerName } = require('librechat-mcp');
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
const {
Constants,
@@ -38,6 +39,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
}
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
if (!req.user?.id) {
logger.error(
@@ -83,7 +85,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
const toolInstance = tool(_call, {
schema,
name: toolKey,
name: normalizedToolKey,
description: description || '',
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
});

View File

@@ -0,0 +1,44 @@
const { removeNullishValues } = require('librechat-data-provider');
const { logger } = require('~/config');
/**
* Loads and maps the Cloudflare Turnstile configuration.
*
* Expected config structure:
*
* turnstile:
* siteKey: "your-site-key-here"
* options:
* language: "auto" // "auto" or an ISO 639-1 language code (e.g. en)
* size: "normal" // Options: "normal", "compact", "flexible", or "invisible"
*
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
* @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration.
*/
function loadTurnstileConfig(config, configDefaults) {
const { turnstile: customTurnstile = {} } = config ?? {};
const { turnstile: defaults = {} } = configDefaults;
/** @type {TCustomConfig['turnstile']} */
const loadedTurnstile = removeNullishValues({
siteKey: customTurnstile.siteKey ?? defaults.siteKey,
options: customTurnstile.options ?? defaults.options,
});
const enabled = Boolean(loadedTurnstile.siteKey);
if (enabled) {
logger.info(
'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2),
);
} else {
logger.info('Turnstile is DISABLED (no siteKey provided).');
}
return loadedTurnstile;
}
module.exports = {
loadTurnstileConfig,
};

View File

@@ -16,7 +16,6 @@ const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
/**
*
* @param {Express.Application} app
*/
const configureSocialLogins = (app) => {
@@ -38,10 +37,7 @@ const configureSocialLogins = (app) => {
passport.use(appleLogin());
}
if (
process.env.OPENID_CLIENT_ID &&
process.env.OPENID_CLIENT_SECRET &&
process.env.OPENID_ISSUER &&
process.env.OPENID_SCOPE &&
process.env.OPENID_ENABLED &&
process.env.OPENID_SESSION_SECRET
) {
logger.info('Configuring OpenID Connect...');

View File

@@ -0,0 +1,52 @@
const { logger } = require('~/config');
const { getCustomConfig } = require('~/server/services/Config');
/**
* Loads the tenant configurations from the custom configuration.
* @returns {Promise<Array>} Array of tenant configurations.
*/
async function getOpenIdTenants() {
try {
const customConfig = await getCustomConfig();
if (customConfig?.openid?.tenants) {
return customConfig.openid.tenants;
}
} catch (err) {
logger.error('Failed to load custom configuration for OpenID tenants:', err);
}
return [];
}
/**
* Chooses the OpenID strategy name based on the email domain.
* It consults the global tenant mapping (built in setupOpenId).
* @param {import('express').Request} req - The Express request object.
* @returns {Promise<string>} - The chosen strategy name.
*/
async function chooseOpenIdStrategy(req) {
if (req.query.email) {
const email = req.query.email;
const domain = email.split('@')[1].toLowerCase();
const tenants = await getOpenIdTenants();
// Iterate over the tenants and return the strategy name of the first matching tenant
for (const tenant of tenants) {
if (tenant.domains) {
const tenantDomains = tenant.domains.split(',').map(s => s.trim().toLowerCase());
if (tenantDomains.includes(domain)) {
// Look up the registered strategy via the global mapping.
if (tenant.name && tenant.name.trim() && global.__openidTenantMapping) {
const mapped = global.__openidTenantMapping.get(tenant.name.trim().toLowerCase());
if (mapped) {
return mapped;
}
}
return 'openid'; // Fallback if no mapping exists.
}
}
}
}
return 'openid';
}
module.exports = { getOpenIdTenants, chooseOpenIdStrategy };

View File

@@ -14,6 +14,7 @@ const staticCache = (staticPath) =>
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
}
},
index: false,
});
module.exports = staticCache;

View File

@@ -23,7 +23,7 @@ const {
// Check required environment variables
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
return null;
module.exports = null;
}
const searchAttributes = [

View File

@@ -1,6 +1,6 @@
const fetch = require('node-fetch');
const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode');
const { decode: jwtDecode } = require('jsonwebtoken');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -8,6 +8,7 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { hashToken } = require('~/server/utils/crypto');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
let crypto;
try {
@@ -105,16 +106,18 @@ function convertToUsername(input, defaultValue = '') {
return defaultValue;
}
async function setupOpenId() {
/**
* Sets up a single OpenID strategy for the given tenant configuration.
* @param {Object} tenant - The tenants OpenID config (issuer, clientId, etc.).
* @param {string} tenant.issuer
* @param {string} tenant.clientId
* @param {string} tenant.clientSecret
* @param {string} strategyName - Unique name for the strategy.
*/
async function setupSingleStrategy(tenant, strategyName) {
try {
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
custom.setHttpOptionsDefaults({
agent: proxyAgent,
});
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
}
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
// Discover the issuer (this performs the .well-known lookup).
const issuer = await Issuer.discover(tenant.issuer);
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
- id_token_signed_response_alg // defaults to 'RS256'
- request_object_signing_alg // defaults to 'RS256'
@@ -124,8 +127,8 @@ async function setupOpenId() {
*/
/** @type {import('openid-client').ClientMetadata} */
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
client_id: tenant.clientId,
client_secret: tenant.clientSecret,
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
};
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
@@ -146,7 +149,7 @@ async function setupOpenId() {
async (tokenset, userinfo, done) => {
try {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
logger.debug('[openidStrategy] verify login tokenset and userinfo', { tokenset, userinfo });
let user = await findUser({ openidId: userinfo.sub });
logger.info(
@@ -265,7 +268,65 @@ async function setupOpenId() {
},
);
passport.use('openid', openidLogin);
passport.use(strategyName, openidLogin);
logger.info(`Configured OpenID strategy [${strategyName}] for issuer: ${tenant.issuer}`);
} catch (err) {
logger.error(`[openidStrategy] Error configuring strategy "${strategyName}":`, err);
}
}
/**
* Reads the YAML configuration and registers strategies for multi-tenant OpenID Connect.
*/
async function setupOpenId() {
try {
// If a proxy is configured, set it for openid-client.
// Set global HTTP options for openid-client
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
custom.setHttpOptionsDefaults({
agent: proxyAgent,
timeout: 10000, // 10,000ms = 10 seconds
});
logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY} with timeout 10000ms`);
} else {
custom.setHttpOptionsDefaults({
timeout: 10000, // Increase the default timeout
});
logger.info('[openidStrategy] Set default timeout to 10000ms');
}
const tenants = await getOpenIdTenants();
// Global mapping: tenant name (lowercase) -> strategy name.
const tenantMapping = new Map();
// If there is one tenant with no domains specified, register it as the default "openid" strategy.
if (tenants.length === 1 && (!tenants[0].domains || tenants[0].domains.trim() === '')) {
await setupSingleStrategy(tenants[0].openid, 'openid');
tenantMapping.set(tenants[0].name?.trim().toLowerCase() || 'openid', 'openid');
logger.info('Configured single-tenant OpenID strategy as "openid"');
} else {
// Otherwise, iterate over each tenant.
for (const tenantCfg of tenants) {
const openidCfg = tenantCfg.openid;
let strategyName = 'openid';
if (tenantCfg.name && tenantCfg.name.trim()) {
strategyName = `openid_${tenantCfg.name.trim()}`;
}else {
logger.warn(
`[openidStrategy] Tenant with issuer ${openidCfg.issuer} has no domains specified; defaulting strategy name to "openid".`,
);
}
await setupSingleStrategy(openidCfg, strategyName);
if (tenantCfg.name && tenantCfg.name.trim()) {
tenantMapping.set(tenantCfg.name.trim().toLowerCase(), strategyName);
}
}
}
// Store the tenant mapping globally so that the helper can choose the correct strategy.
global.__openidTenantMapping = tenantMapping;
} catch (err) {
logger.error('[openidStrategy]', err);
}

View File

@@ -33,6 +33,15 @@ jest.mock('~/config', () => ({
},
}));
// IMPORTANT: Mock the openid helper to return our desired tenant configuration.
jest.mock('~/server/utils/openidHelper', () => ({
getOpenIdTenants: jest.fn(),
chooseOpenIdStrategy: jest.fn(), // Not used in these tests.
}));
// Import our mocked helper so we can set its return value.
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
// Mock Issuer.discover so that setupOpenId gets a fake issuer and client
Issuer.discover = jest.fn().mockResolvedValue({
id_token_signing_alg_values_supported: ['RS256'],
@@ -97,6 +106,21 @@ describe('setupOpenId', () => {
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
// Set up our mocked tenant configuration.
// Here we simulate a single tenant with an empty domains field.
// (Our updated multi-tenant code uses the tenant name to build the strategy.)
getOpenIdTenants.mockResolvedValue([
{
name: 'tenant1',
domains: '', // Using an empty string so the single-tenant branch is taken.
openid: {
issuer: process.env.OPENID_ISSUER,
clientId: process.env.OPENID_CLIENT_ID,
clientSecret: process.env.OPENID_CLIENT_SECRET,
},
},
]);
// Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({
roles: ['requiredRole'],

View File

@@ -8,6 +8,7 @@ jest.mock('winston', () => {
mockFormatFunction.printf = jest.fn();
mockFormatFunction.errors = jest.fn();
mockFormatFunction.splat = jest.fn();
mockFormatFunction.json = jest.fn();
return {
format: mockFormatFunction,
createLogger: jest.fn().mockReturnValue({
@@ -19,6 +20,7 @@ jest.mock('winston', () => {
transports: {
Console: jest.fn(),
DailyRotateFile: jest.fn(),
File: jest.fn(),
},
addColors: jest.fn(),
};

View File

@@ -6,3 +6,7 @@ process.env.BAN_VIOLATIONS = 'true';
process.env.BAN_DURATION = '7200000';
process.env.BAN_INTERVAL = '20';
process.env.CI = 'true';
process.env.JWT_SECRET = 'test';
process.env.JWT_REFRESH_SECRET = 'test';
process.env.CREDS_KEY = 'test';
process.env.CREDS_IV = 'test';

View File

@@ -34,6 +34,7 @@
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",
"@headlessui/react": "^2.1.2",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",

View File

@@ -142,6 +142,7 @@ export enum Panel {
builder = 'builder',
actions = 'actions',
model = 'model',
version = 'version',
}
export type FileSetter =
@@ -535,6 +536,7 @@ export type NewConversationParams = {
buildDefault?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
};
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;

View File

@@ -1,9 +1,10 @@
import { useForm } from 'react-hook-form';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useContext } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { ThemeContext, useLocalize } from '~/hooks';
type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void;
@@ -14,6 +15,8 @@ type TLoginFormProps = {
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const {
register,
getValues,
@@ -21,9 +24,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
formState: { errors },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username;
const validTheme = theme === 'dark' ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
@@ -96,20 +102,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
},
})}
aria-invalid={!!errors.email}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="email"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '')
@@ -131,20 +129,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="password"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
@@ -159,15 +149,29 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
{localize('com_auth_password_forgot')}
</a>
)}
{requireCaptcha && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile!.siteKey}
options={{
...startupConfig.turnstile!.options,
theme: validTheme,
}}
onSuccess={setTurnstileToken}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}
<div className="mt-6">
<button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
"
disabled={requireCaptcha && !turnstileToken}
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
>
{localize('com_auth_continue')}
</button>

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { OpenIDIcon } from '~/components';
interface MultiTenantOpenIDProps {
serverDomain: string;
openidLabel: string;
openidImageUrl: string;
localize: (key: string) => string;
}
/**
* When multitenant mode is enabled (startupConfig.emailLoginEnabled === true),
* we render a form for the user to enter their email. When submitted, we perform a GET
* request (via redirect) to /oauth/openid with the email as a query parameter.
* If, for some reason, no email is provided, we simply redirect to /oauth/openid.
*/
function MultiTenantOpenID({
serverDomain,
openidLabel,
openidImageUrl,
localize,
}: MultiTenantOpenIDProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ email: string }>();
const onSubmit = (data: { email: string }) => {
// If an email is provided, include it as a query parameter.
// Otherwise, simply redirect without an email.
const emailQuery =
data.email && data.email.trim() !== ''
? `?email=${encodeURIComponent(data.email)}`
: '';
window.location.href = `${serverDomain}/oauth/openid${emailQuery}`;
};
const renderError = (fieldName: string) => {
const errorMessage = errors[fieldName]?.message;
return errorMessage ? (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{String(errorMessage)}
</span>
) : null;
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="mt-2">
<div className="mb-4">
<div className="relative">
<input
type="email"
id="email"
autoComplete="email"
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
pattern: {
value: /\S+@\S+\.\S+/,
message: localize('com_auth_email_pattern'),
},
})}
aria-invalid={!!errors.email}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
placeholder=" "
/>
<label
htmlFor="email"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
>
{localize('com_auth_email_address')}
</label>
</div>
{renderError('email')}
</div>
<button
type="submit"
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
data-testid="openid"
>
{openidImageUrl ? (
<img src={openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)}
<p>{openidLabel}</p>
</button>
</form>
);
}
export default MultiTenantOpenID;

View File

@@ -1,16 +1,18 @@
import { useForm } from 'react-hook-form';
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from './ErrorMessage';
import { Spinner } from '~/components/svg';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';
const Registration: React.FC = () => {
const navigate = useNavigate();
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
const {
@@ -24,10 +26,15 @@ const Registration: React.FC = () => {
const [errorMessage, setErrorMessage] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [countdown, setCountdown] = useState<number>(3);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const token = queryParams.get('token');
const validTheme = theme === 'dark' ? 'dark' : 'light';
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const registerUser = useRegisterUserMutation({
onMutate: () => {
@@ -69,21 +76,13 @@ const Registration: React.FC = () => {
validation,
)}
aria-invalid={!!errors[id]}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
data-testid={id}
/>
<label
htmlFor={id}
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize(label)}
</label>
@@ -178,17 +177,32 @@ const Registration: React.FC = () => {
validate: (value: string) =>
value === password || localize('com_auth_password_not_match'),
})}
{startupConfig?.turnstile?.siteKey && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}
<div className="mt-6">
<button
disabled={Object.keys(errors).length > 0}
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
(requireCaptcha && !turnstileToken)
}
type="submit"
aria-label="Submit registration"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>

View File

@@ -1,10 +1,16 @@
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
import React from 'react';
import {
GoogleIcon,
FacebookIcon,
OpenIDIcon,
GithubIcon,
DiscordIcon,
AppleIcon,
} from '~/components';
import SocialButton from './SocialButton';
import { useLocalize } from '~/hooks';
import { TStartupConfig } from 'librechat-data-provider';
import MultiTenantOpenID from './MultiTenantOpenID';
function SocialLoginRender({
startupConfig,
@@ -73,23 +79,37 @@ function SocialLoginRender({
id="apple"
/>
),
openid: startupConfig.openidLoginEnabled && (
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
),
openid:
startupConfig.openidLoginEnabled &&
(startupConfig.openidMultiTenantEnabled ? (
<MultiTenantOpenID
key="openid"
openidImageUrl={startupConfig.openidImageUrl}
serverDomain={startupConfig.serverDomain}
openidLabel={startupConfig.openidLabel}
localize={localize}
/>
) : (
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img
src={startupConfig.openidImageUrl}
alt="OpenID Logo"
className="h-5 w-5"
/>
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
)),
};
return (

View File

@@ -206,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<form
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex flex-row gap-3 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
centerFormOnLanding &&
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
!isSubmitting &&

View File

@@ -1,9 +1,8 @@
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileIcon from '~/components/svg/Files/FileIcon';
import ProgressCircle from './ProgressCircle';
import { Spinner } from '~/components';
import SourceIcon from './SourceIcon';
import { useProgress } from '~/hooks';
import { cn } from '~/utils';
const FilePreview = ({
@@ -19,28 +18,15 @@ const FilePreview = ({
};
className?: string;
}) => {
const radius = 55;
const circumference = 2 * Math.PI * radius;
const progress = useProgress(
file?.['progress'] ?? 1,
0.001,
(file as ExtendedFile | undefined)?.size ?? 1,
);
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.5s linear',
};
return (
<div className={cn('relative size-10 shrink-0 overflow-hidden rounded-xl', className)}>
<FileIcon file={file} fileType={fileType} />
<SourceIcon source={file?.source} isCodeFile={!!file?.['metadata']?.fileIdentifier} />
{progress < 1 && (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
{typeof file?.['progress'] === 'number' && file?.['progress'] < 1 && (
<Spinner
bgOpacity={0.2}
color="white"
className="absolute inset-0 m-2.5 flex items-center justify-center"
/>
)}
</div>

View File

@@ -75,20 +75,20 @@ export default function FileRow({
const renderFiles = () => {
const rowStyle = isRTL
? {
display: 'flex',
flexDirection: 'row-reverse',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
}
display: 'flex',
flexDirection: 'row-reverse',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
}
: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
};
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
width: '100%',
maxWidth: '100%',
};
return (
<div style={rowStyle as React.CSSProperties}>

View File

@@ -161,7 +161,7 @@ const ImagePreview = ({
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialogContent
showCloseButton={false}
className={cn('w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto')}
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
disableScroll={false}
>
<img

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ListFilter } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import {
flexRender,
getCoreRowModel,
@@ -36,6 +37,7 @@ import { TrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import { useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -60,12 +62,14 @@ type Style = {
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const [isDeleting, setIsDeleting] = useState(false);
const setFiles = useSetRecoilState(store.filesByIndex(0));
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const [rowSelection, setRowSelection] = useState({});
const [sorting, setSorting] = useState<SortingState>([]);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const table = useReactTable({
data,
@@ -96,7 +100,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
const filesToDelete = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original);
deleteFiles({ files: filesToDelete as TFile[] });
deleteFiles({ files: filesToDelete as TFile[], setFiles });
setRowSelection({});
}}
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
@@ -218,13 +222,10 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
<div className="flex items-center justify-end gap-2 py-4">
<div className="ml-2 flex-1 truncate text-xs text-muted-foreground sm:ml-4 sm:text-sm">
<span className="hidden sm:inline">
{localize(
'com_files_number_selected',
{
0: `${table.getFilteredSelectedRowModel().rows.length}`,
1: `${table.getFilteredRowModel().rows.length}`,
},
)}
{localize('com_files_number_selected', {
0: `${table.getFilteredSelectedRowModel().rows.length}`,
1: `${table.getFilteredRowModel().rows.length}`,
})}
</span>
<span className="sm:hidden">
{`${table.getFilteredSelectedRowModel().rows.length}/${

View File

@@ -79,19 +79,19 @@ export default function HeaderOptions({
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
</div>
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<OptionsPopover

View File

@@ -1,17 +1,9 @@
import { X } from 'lucide-react';
export default function CancelledIcon() {
return (
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-gray-300 text-white"
style={{ opacity: 1, transform: 'none' }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.32256 1.48447C7.59011 1.16827 7.55068 0.695034 7.23447 0.427476C6.91827 0.159918 6.44503 0.199354 6.17748 0.515559L4.00002 3.08892L1.82256 0.515559C1.555 0.199354 1.08176 0.159918 0.765559 0.427476C0.449355 0.695034 0.409918 1.16827 0.677476 1.48447L3.01755 4.25002L0.677476 7.01556C0.409918 7.33176 0.449354 7.805 0.765559 8.07256C1.08176 8.34011 1.555 8.30068 1.82256 7.98447L4.00002 5.41111L6.17748 7.98447C6.44503 8.30068 6.91827 8.34011 7.23447 8.07256C7.55068 7.805 7.59011 7.33176 7.32256 7.01556L4.98248 4.25002L7.32256 1.48447Z"
fill="currentColor"
/>
</svg>
<div className="flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary">
<X className="size-4" />
</div>
);
}

View File

@@ -1,31 +1,23 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CodeInProgress } from './Parts/CodeProgress';
import { useProgress, useLocalize } from '~/hooks';
import ProgressText from './ProgressText';
import FinishedIcon from './FinishedIcon';
import MarkdownLite from './MarkdownLite';
import store from '~/store';
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
export default function CodeAnalyze({
initialProgress = 0.1,
code,
outputs = [],
isSubmitting,
}: {
initialProgress: number;
code: string;
outputs: Record<string, unknown>[];
isSubmitting: boolean;
}) {
const localize = useLocalize();
const progress = useProgress(initialProgress);
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const offset = circumference - progress * circumference;
const logs = outputs.reduce((acc, output) => {
if (output['logs']) {
@@ -37,19 +29,6 @@ export default function CodeAnalyze({
return (
<>
<div className="my-2.5 flex items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">
{progress < 1 ? (
<CodeInProgress
offset={offset}
radius={radius}
progress={progress}
isSubmitting={isSubmitting}
circumference={circumference}
/>
) : (
<FinishedIcon />
)}
</div>
<ProgressText
progress={progress}
onClick={() => setShowCode((prev) => !prev)}

View File

@@ -3,10 +3,10 @@ import { useRecoilValue, useRecoilState } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider';
import { ThinkingButton } from '~/components/Artifacts/Thinking';
import EditTextPart from './Parts/EditTextPart';
import useLocalize from '~/hooks/useLocalize';
import { mapAttachments } from '~/utils/map';
import { MessageContext } from '~/Providers';
import { EditTextPart } from './Parts';
import store from '~/store';
import Part from './Part';

View File

@@ -1,42 +1,42 @@
import * as Dialog from '@radix-ui/react-dialog';
import { X, ArrowDownToLine } from 'lucide-react';
import { Button, OGDialog, OGDialogContent } from '~/components';
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) {
return (
<Dialog.Portal>
<Dialog.Overlay
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
style={{ pointerEvents: 'auto' }}
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
showCloseButton={false}
className="h-full w-full rounded-none bg-transparent"
disableScroll={false}
overlayClassName="bg-surface-primary opacity-95 z-50"
>
<Dialog.Close asChild>
<button
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
type="button"
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</Dialog.Close>
<Dialog.Content
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
tabIndex={-1}
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
>
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
</Dialog.Content>
</Dialog.Overlay>
</Dialog.Portal>
<X className="size-6" />
</Button>
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
<ArrowDownToLine className="size-6" />
</Button>
</div>
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
showCloseButton={false}
className="w-11/12 overflow-x-auto rounded-none bg-transparent p-4 shadow-none sm:w-auto"
disableScroll={false}
overlayClassName="bg-transparent"
>
<img
src={src}
alt="Uploaded image"
className="max-w-screen h-full max-h-screen w-full object-contain"
/>
</OGDialogContent>
</OGDialog>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -1,11 +1,10 @@
export default function FinishedIcon() {
return (
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-brand-purple text-white"
style={{ opacity: 1, transform: 'none' }}
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
data-projection-id="162"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
<path
fillRule="evenodd"
clipRule="evenodd"

View File

@@ -1,27 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import * as Dialog from '@radix-ui/react-dialog';
import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage';
import { cn } from '~/utils';
const scaleImage = ({
originalWidth,
originalHeight,
containerRef,
}: {
originalWidth?: number;
originalHeight?: number;
containerRef: React.RefObject<HTMLDivElement>;
}) => {
const containerWidth = containerRef.current?.offsetWidth ?? 0;
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
return { width: 'auto', height: 'auto' };
}
const aspectRatio = originalWidth / originalHeight;
const scaledWidth = Math.min(containerWidth, originalWidth);
const scaledHeight = scaledWidth / aspectRatio;
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
};
import { Skeleton } from '~/components';
const Image = ({
imagePath,
@@ -41,6 +22,7 @@ const Image = ({
};
className?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -56,39 +38,63 @@ const Image = ({
[placeholderDimensions, height, width],
);
const downloadImage = () => {
const link = document.createElement('a');
link.href = imagePath;
link.download = altText;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Dialog.Root>
<div ref={containerRef}>
<div
className={cn(
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-surface-active-alt text-text-secondary-alt',
className,
)}
<div ref={containerRef}>
<div
className={cn(
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
className,
)}
>
<button
type="button"
aria-label={`View ${altText} in dialog`}
onClick={() => setIsOpen(true)}
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Dialog.Trigger asChild>
<button type="button" aria-haspopup="dialog" aria-expanded="false">
<LazyLoadImage
alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={imagePath}
style={{
width: scaledWidth,
height: 'auto',
color: 'transparent',
}}
placeholder={<div style={{ width: scaledWidth, height: scaledHeight }} />}
<LazyLoadImage
alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={imagePath}
style={{
width: `${scaledWidth}`,
height: 'auto',
color: 'transparent',
display: 'block',
}}
placeholder={
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
aria-label="Loading image"
aria-busy="true"
/>
</button>
</Dialog.Trigger>
</div>
}
/>
</button>
{isLoaded && (
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={imagePath}
downloadImage={downloadImage}
/>
)}
</div>
{isLoaded && <DialogImage src={imagePath} height={height} width={width} />}
</Dialog.Root>
</div>
);
};

View File

@@ -7,17 +7,13 @@ import {
} from 'librechat-data-provider';
import { memo } from 'react';
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
import { ErrorMessage } from './MessageContent';
import AgentUpdate from './Parts/AgentUpdate';
import ExecuteCode from './Parts/ExecuteCode';
import RetrievalCall from './RetrievalCall';
import Reasoning from './Parts/Reasoning';
import EmptyText from './Parts/EmptyText';
import CodeAnalyze from './CodeAnalyze';
import Container from './Container';
import ToolCall from './ToolCall';
import ImageGen from './ImageGen';
import Text from './Parts/Text';
import Image from './Image';
type PartProps = {
@@ -93,8 +89,21 @@ const Part = memo(
<ExecuteCode
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
attachments={attachments}
/>
);
} else if (
isToolCall &&
(toolCall.name === 'image_gen_oai' || toolCall.name === 'image_edit_oai')
) {
return (
<OpenAIImageGen
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
toolName={toolCall.name}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
attachments={attachments}
/>
);
@@ -118,7 +127,6 @@ const Part = memo(
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
isSubmitting={isSubmitting}
/>
);
} else if (

View File

@@ -1,25 +1,82 @@
import { memo } from 'react';
import { memo, useState, useEffect } from 'react';
import { imageExtRegex } from 'librechat-data-provider';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import Image from '~/components/Chat/Messages/Content/Image';
import { useAttachmentLink } from './LogLink';
import { cn } from '~/utils';
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
const { handleDownload } = useAttachmentLink({
href: attachment.filepath,
filename: attachment.filename,
href: attachment.filepath ?? '',
filename: attachment.filename ?? '',
});
const extension = attachment.filename.split('.').pop();
const extension = attachment.filename?.split('.').pop();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, []);
if (!attachment.filepath) {
return null;
}
return (
<div
className={cn(
'file-attachment-container',
'transition-all duration-300 ease-out',
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<FileContainer
file={attachment}
onClick={handleDownload}
overrideType={extension}
containerClassName="max-w-fit"
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
/>
</div>
);
});
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const [isLoaded, setIsLoaded] = useState(false);
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
useEffect(() => {
setIsLoaded(false);
const timer = setTimeout(() => setIsLoaded(true), 100);
return () => clearTimeout(timer);
}, [attachment]);
return (
<FileContainer
file={attachment}
onClick={handleDownload}
overrideType={extension}
containerClassName="max-w-fit"
buttonClassName="hover:cursor-pointer hover:bg-surface-secondary active:bg-surface-secondary focus:bg-surface-secondary hover:border-border-heavy active:border-border-heavy"
/>
<div
className={cn(
'image-attachment-container',
'transition-all duration-500 ease-out',
isLoaded ? 'scale-100 opacity-100' : 'scale-[0.98] opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<Image
altText={attachment.filename}
imagePath={filepath ?? ''}
height={height ?? 0}
width={width ?? 0}
className="mb-4"
/>
</div>
);
});
@@ -27,20 +84,60 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
if (!attachment) {
return null;
}
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null;
if (isImage) {
return (
<Image
altText={attachment.filename}
imagePath={filepath}
height={height}
width={width}
className="mb-4"
/>
);
return <ImageAttachment attachment={attachment} />;
} else if (!attachment.filepath) {
return null;
}
return <FileAttachment attachment={attachment} />;
}
export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }) {
if (!attachments || attachments.length === 0) {
return null;
}
const fileAttachments: TAttachment[] = [];
const imageAttachments: TAttachment[] = [];
attachments.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename) &&
width != null &&
height != null &&
filepath != null;
if (isImage) {
imageAttachments.push(attachment);
} else {
fileAttachments.push(attachment);
}
});
return (
<>
{fileAttachments.length > 0 && (
<div className="my-2 flex flex-wrap items-center gap-2.5">
{fileAttachments.map((attachment, index) =>
attachment.filepath ? (
<FileAttachment attachment={attachment} key={`file-${index}`} />
) : null,
)}
</div>
)}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap items-center">
{imageAttachments.map((attachment, index) => (
<ImageAttachment attachment={attachment} key={`image-${index}`} />
))}
</div>
)}
</>
);
}

View File

@@ -1,90 +0,0 @@
import ProgressCircle from '~/components/Chat/Messages/Content/ProgressCircle';
import CancelledIcon from '~/components/Chat/Messages/Content/CancelledIcon';
export const CodeInProgress = ({
offset,
circumference,
radius,
isSubmitting,
progress,
}: {
progress: number;
offset: number;
circumference: number;
radius: number;
isSubmitting: boolean;
}) => {
if (progress < 1 && !isSubmitting) {
return <CancelledIcon />;
}
return (
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="77"
>
<div className="absolute bottom-[1.5px] right-[1.5px]">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 20 20"
width="20"
height="20"
style={{ transform: 'translate3d(0px, 0px, 0px)' }}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<clipPath id="__lottie_element_11">
<rect width="20" height="20" x="0" y="0" />
</clipPath>
</defs>
<g clipPath="url(#__lottie_element_11)">
<g
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
className="slide-from-left"
>
<g opacity="1" transform="matrix(1,0,0,1,7.026679992675781,8.834091186523438)">
<path
fill="rgb(177,98,253)"
fillOpacity="1"
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
fillOpacity="0"
stroke="rgb(177,98,253)"
strokeOpacity="1"
strokeWidth="0.201031"
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
/>
</g>
</g>
<g
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
className="slide-to-down"
>
<g opacity="1" transform="matrix(1,0,0,1,11.79640007019043,13.512199401855469)">
<path
fill="rgb(177,98,253)"
fillOpacity="1"
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
fillOpacity="0"
stroke="rgb(177,98,253)"
strokeOpacity="1"
strokeWidth="0.100515"
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
/>
</g>
</g>
</g>
</svg>
</div>
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
</div>
);
};

View File

@@ -1,13 +1,12 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import type { TAttachment } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import { useProgress, useLocalize } from '~/hooks';
import { CodeInProgress } from './CodeProgress';
import Attachment from './Attachment';
import { AttachmentGroup } from './Attachment';
import Stdout from './Stdout';
import { cn } from '~/utils';
import store from '~/store';
interface ParsedArgs {
@@ -45,46 +44,101 @@ export function useParseArgs(args: string): ParsedArgs {
}, [args]);
}
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
export default function ExecuteCode({
initialProgress = 0.1,
args,
output = '',
isSubmitting,
attachments,
}: {
initialProgress: number;
args: string;
output?: string;
isSubmitting: boolean;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const codeContentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const hasOutput = output.length > 0;
const outputRef = useRef<string>(output);
const prevShowCodeRef = useRef<boolean>(showCode);
const { lang, code } = useParseArgs(args);
const progress = useProgress(initialProgress);
const offset = circumference - progress * circumference;
useEffect(() => {
if (output !== outputRef.current) {
outputRef.current = output;
if (showCode && codeContentRef.current) {
setTimeout(() => {
if (codeContentRef.current) {
const newHeight = codeContentRef.current.scrollHeight;
setContentHeight(newHeight);
}
}, 10);
}
}
}, [output, showCode]);
useEffect(() => {
if (showCode !== prevShowCodeRef.current) {
prevShowCodeRef.current = showCode;
if (showCode && codeContentRef.current) {
setIsAnimating(true);
requestAnimationFrame(() => {
if (codeContentRef.current) {
const height = codeContentRef.current.scrollHeight;
setContentHeight(height);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 500);
return () => clearTimeout(timer);
});
} else if (!showCode) {
setIsAnimating(true);
setContentHeight(0);
const timer = setTimeout(() => {
setIsAnimating(false);
}, 500);
return () => clearTimeout(timer);
}
}
}, [showCode]);
useEffect(() => {
if (!codeContentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showCode && !isAnimating) {
for (const entry of entries) {
if (entry.target === codeContentRef.current) {
setContentHeight(entry.contentRect.height);
}
}
}
});
resizeObserver.observe(codeContentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showCode, isAnimating]);
return (
<>
<div className="my-2.5 flex items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">
{progress < 1 ? (
<CodeInProgress
offset={offset}
radius={radius}
progress={progress}
isSubmitting={isSubmitting}
circumference={circumference}
/>
) : (
<FinishedIcon />
)}
</div>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowCode((prev) => !prev)}
@@ -94,31 +148,71 @@ export default function ExecuteCode({
isExpanded={showCode}
/>
</div>
{showCode && (
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
<MarkdownLite
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
codeExecution={false}
/>
{output.length > 0 && (
<div className="bg-gray-700 p-4 text-xs">
<div
className="prose flex flex-col-reverse text-white"
style={{
color: 'white',
}}
>
<div
className="relative mb-2"
style={{
height: showCode ? contentHeight : 0,
overflow: 'hidden',
transition:
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: showCode ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<div
className={cn(
'code-analyze-block mt-0.5 overflow-hidden rounded-xl bg-surface-primary',
showCode && 'shadow-lg',
)}
ref={codeContentRef}
style={{
transform: showCode ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showCode ? 1 : 0,
transition:
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{showCode && (
<div
style={{
transform: showCode ? 'translateY(0)' : 'translateY(-4px)',
opacity: showCode ? 1 : 0,
transition:
'transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<MarkdownLite
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
codeExecution={false}
/>
</div>
)}
{hasOutput && (
<div
className={cn(
'bg-surface-tertiary p-4 text-xs',
showCode ? 'border-t border-surface-primary-contrast' : '',
)}
style={{
transform: showCode ? 'translateY(0)' : 'translateY(-6px)',
opacity: showCode ? 1 : 0,
transition:
'transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s, opacity 0.45s cubic-bezier(0.19, 1, 0.22, 1) 0.05s',
boxShadow: showCode ? '0 -1px 0 rgba(0,0,0,0.05)' : 'none',
}}
>
<div className="prose flex flex-col-reverse">
<Stdout output={output} />
</div>
</div>
)}
</div>
)}
<div className="mb-2 flex flex-wrap items-center gap-2.5">
{attachments?.map((attachment, index) => (
<Attachment attachment={attachment} key={index} />
))}
</div>
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
</>
);
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image';
import ProgressText from './ProgressText';
import { PixelCard } from '~/components';
import { scaleImage } from '~/utils';
export default function OpenAIImageGen({
initialProgress = 0.1,
isSubmitting,
toolName,
args: _args = '',
output,
attachments,
}: {
initialProgress: number;
isSubmitting: boolean;
toolName: string;
args: string | Record<string, unknown>;
output?: string | null;
attachments?: TAttachment[];
}) {
const [progress, setProgress] = useState(initialProgress);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
let width: number | undefined;
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high';
try {
const argsObj = typeof _args === 'string' ? JSON.parse(_args) : _args;
if (argsObj && typeof argsObj.size === 'string') {
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
if (!isNaN(w) && !isNaN(h)) {
width = w;
height = h;
}
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
width = undefined;
height = undefined;
}
if (argsObj && typeof argsObj.quality === 'string') {
const q = argsObj.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
}
} catch (e) {
width = undefined;
height = undefined;
}
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
const attachment = attachments?.[0];
const {
width: imageWidth,
height: imageHeight,
filepath = null,
filename = '',
} = (attachment as TFile & TAttachmentMetadata) || {};
let origWidth = width ?? imageWidth;
let origHeight = height ?? imageHeight;
if (origWidth === undefined || origHeight === undefined) {
origWidth = 1024;
origHeight = 1024;
}
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
const containerRef = useRef<HTMLDivElement>(null);
const updateDimensions = useCallback(() => {
if (origWidth && origHeight && containerRef.current) {
const scaled = scaleImage({
originalWidth: origWidth,
originalHeight: origHeight,
containerRef,
});
setDimensions(scaled);
}
}, [origWidth, origHeight]);
useEffect(() => {
if (isSubmitting) {
setProgress(initialProgress);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
let baseDuration = 20000;
if (quality === 'low') {
baseDuration = 10000;
} else if (quality === 'high') {
baseDuration = 50000;
}
// adding some jitter (±30% of base)
const jitter = Math.floor(baseDuration * 0.3);
const totalDuration = Math.floor(Math.random() * jitter) + baseDuration;
const updateInterval = 200;
const totalSteps = totalDuration / updateInterval;
let currentStep = 0;
intervalRef.current = setInterval(() => {
currentStep++;
if (currentStep >= totalSteps) {
clearInterval(intervalRef.current as NodeJS.Timeout);
setProgress(0.9);
} else {
const progressRatio = currentStep / totalSteps;
let mapRatio: number;
if (progressRatio < 0.8) {
mapRatio = Math.pow(progressRatio, 1.1);
} else {
const sub = (progressRatio - 0.8) / 0.2;
mapRatio = 0.8 + (1 - Math.pow(1 - sub, 2)) * 0.2;
}
const scaledProgress = 0.1 + mapRatio * 0.8;
setProgress(scaledProgress);
}
}, updateInterval);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProgress, quality]);
useEffect(() => {
if (initialProgress >= 1 || cancelled) {
setProgress(initialProgress);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
}, [initialProgress, cancelled]);
useEffect(() => {
updateDimensions();
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [updateDimensions]);
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div>
{/* {showInfo && hasInfo && (
<ToolCallInfo
key="tool-call-info"
input={args ?? ''}
output={output}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && initialProgress < 1}
/>
)} */}
<div className="relative mb-2 flex w-full justify-start">
<div ref={containerRef} className="w-full max-w-lg">
{dimensions.width !== 'auto' && progress < 1 && (
<PixelCard
variant="default"
progress={progress}
randomness={0.6}
width={dimensions.width}
height={dimensions.height}
/>
)}
<Image
altText={filename}
imagePath={filepath ?? ''}
width={Number(dimensions.width?.split('px')[0])}
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,62 @@
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ProgressText({
progress,
error,
toolName = 'image_gen_oai',
}: {
progress: number;
error?: boolean;
toolName: string;
}) {
const localize = useLocalize();
const getText = () => {
if (error) {
return localize('com_ui_error');
}
if (toolName === 'image_edit_oai') {
if (progress >= 1) {
return localize('com_ui_image_edited');
}
if (progress >= 0.7) {
return localize('com_ui_final_touch');
}
if (progress >= 0.5) {
return localize('com_ui_adding_details');
}
if (progress >= 0.3) {
return localize('com_ui_edit_editing_image');
}
return localize('com_ui_getting_started');
}
if (progress >= 1) {
return localize('com_ui_image_created');
}
if (progress >= 0.7) {
return localize('com_ui_final_touch');
}
if (progress >= 0.5) {
return localize('com_ui_adding_details');
}
if (progress >= 0.3) {
return localize('com_ui_creating_image');
}
return localize('com_ui_getting_started');
};
const text = getText();
return (
<div
className={cn(
'progress-text-content pointer-events-none absolute left-0 top-0 inline-flex w-full items-center gap-2 overflow-visible whitespace-nowrap',
)}
>
<span className={`font-medium ${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as OpenAIImageGen } from './OpenAIImageGen';

View File

@@ -17,7 +17,7 @@ const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
return (
processedContent && (
<pre className="shrink-0">
<div>{processedContent}</div>
<div className="text-text-primary">{processedContent}</div>
</pre>
)
);

View File

@@ -0,0 +1,10 @@
export * from './Attachment';
export * from './OpenAIImageGen';
export { default as Text } from './Text';
export { default as Reasoning } from './Reasoning';
export { default as EmptyText } from './EmptyText';
export { default as LogContent } from './LogContent';
export { default as ExecuteCode } from './ExecuteCode';
export { default as AgentUpdate } from './AgentUpdate';
export { default as EditTextPart } from './EditTextPart';

View File

@@ -1,4 +1,8 @@
import * as Popover from '@radix-ui/react-popover';
import { ChevronDown, ChevronUp } from 'lucide-react';
import CancelledIcon from './CancelledIcon';
import FinishedIcon from './FinishedIcon';
import { Spinner } from '~/components';
import { cn } from '~/utils';
const wrapperClass =
@@ -10,7 +14,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
<div className={wrapperClass}>
<Popover.Trigger asChild>
<div
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>
@@ -24,7 +28,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
return (
<div className={wrapperClass}>
<div
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>
@@ -43,6 +47,7 @@ export default function ProgressText({
hasInput = true,
popover = false,
isExpanded = false,
error = false,
}: {
progress: number;
onClick?: () => void;
@@ -52,33 +57,28 @@ export default function ProgressText({
hasInput?: boolean;
popover?: boolean;
isExpanded?: boolean;
error?: boolean;
}) {
const text = progress < 1 ? (authText ?? inProgressText) : finishedText;
return (
<Wrapper popover={popover}>
<button
type="button"
className={cn('inline-flex items-center gap-1', hasInput ? '' : 'pointer-events-none')}
className={cn(
'inline-flex w-full items-center gap-2',
hasInput ? '' : 'pointer-events-none',
)}
disabled={!hasInput}
onClick={onClick}
onClick={hasInput ? onClick : undefined}
>
{text}
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
className={isExpanded ? 'rotate-180' : 'rotate-0'}
>
<path
className={hasInput ? '' : 'stroke-transparent'}
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{progress < 1 ? <Spinner /> : error ? <CancelledIcon /> : <FinishedIcon />}
<span className={`${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
{hasInput &&
(isExpanded ? (
<ChevronUp className="size-4 translate-y-[1px]" />
) : (
<ChevronDown className="size-4 translate-y-[1px]" />
))}
</button>
</Wrapper>
);

View File

@@ -1,22 +1,13 @@
import { useMemo } from 'react';
import * as Popover from '@radix-ui/react-popover';
import { ShieldCheck, TriangleAlert } from 'lucide-react';
import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react';
import { TriangleAlert } from 'lucide-react';
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import ProgressCircle from './ProgressCircle';
import InProgressCall from './InProgressCall';
import Attachment from './Parts/Attachment';
import CancelledIcon from './CancelledIcon';
import { useLocalize, useProgress } from '~/hooks';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
import FinishedIcon from './FinishedIcon';
import ToolPopover from './ToolPopover';
import WrenchIcon from './WrenchIcon';
import { useProgress } from '~/hooks';
import { logger } from '~/utils';
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
import { Button } from '~/components';
import { logger, cn } from '~/utils';
export default function ToolCall({
initialProgress = 0.1,
@@ -37,11 +28,16 @@ export default function ToolCall({
expires_at?: number;
}) {
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const prevShowInfoRef = useRef<boolean>(showInfo);
const { function_name, domain, isMCPToolCall } = useMemo(() => {
if (typeof name !== 'string') {
return { function_name: '', domain: null, isMCPToolCall: false };
}
if (name.includes(Constants.mcp_delimiter)) {
const [func, server] = name.split(Constants.mcp_delimiter);
return {
@@ -50,7 +46,6 @@ export default function ToolCall({
isMCPToolCall: true,
};
}
const [func, _domain] = name.includes(actionDelimiter)
? name.split(actionDelimiter)
: [name, ''];
@@ -68,7 +63,6 @@ export default function ToolCall({
if (typeof _args === 'string') {
return _args;
}
try {
return JSON.stringify(_args, null, 2);
} catch (e) {
@@ -98,42 +92,8 @@ export default function ToolCall({
}
}, [auth]);
const progress = useProgress(error === true ? 1 : initialProgress);
const progress = useProgress(initialProgress);
const cancelled = (!isSubmitting && progress < 1) || error === true;
const offset = circumference - progress * circumference;
const renderIcon = () => {
if (progress < 1 && authDomain.length > 0) {
return (
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="849"
>
<div>
<ShieldCheck />
</div>
</div>
);
} else if (progress < 1) {
return (
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="849"
>
<div>
<WrenchIcon />
</div>
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
</div>
</InProgressCall>
);
}
return cancelled ? <CancelledIcon /> : <FinishedIcon />;
};
const getFinishedText = () => {
if (cancelled) {
@@ -148,51 +108,125 @@ export default function ToolCall({
return localize('com_assistants_completed_function', { 0: function_name });
};
useLayoutEffect(() => {
if (showInfo !== prevShowInfoRef.current) {
prevShowInfoRef.current = showInfo;
setIsAnimating(true);
if (showInfo && contentRef.current) {
requestAnimationFrame(() => {
if (contentRef.current) {
const height = contentRef.current.scrollHeight;
setContentHeight(height + 4);
}
});
} else {
setContentHeight(0);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 400);
return () => clearTimeout(timer);
}
}, [showInfo]);
useEffect(() => {
if (!contentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showInfo && !isAnimating) {
for (const entry of entries) {
if (entry.target === contentRef.current) {
setContentHeight(entry.contentRect.height + 4);
}
}
}
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showInfo, isAnimating]);
return (
<Popover.Root>
<div className="my-2.5 flex flex-wrap items-center gap-2.5">
<div className="flex w-full items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">{renderIcon()}</div>
<ProgressText
progress={cancelled ? 1 : progress}
inProgressText={localize('com_assistants_running_action')}
authText={
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
}
finishedText={getFinishedText()}
hasInput={hasInfo}
popover={true}
/>
{hasInfo && (
<ToolPopover
input={args ?? ''}
output={output}
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
/>
)}
</div>
{auth != null && auth && progress < 1 && !cancelled && (
<div className="flex w-full flex-col gap-2.5">
<div className="mb-1 mt-2">
<a
className="inline-flex items-center justify-center gap-2 rounded-3xl bg-surface-tertiary px-4 py-2 text-sm font-medium hover:bg-surface-hover"
href={auth}
target="_blank"
rel="noopener noreferrer"
>
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
</a>
</div>
<p className="flex items-center text-xs text-text-secondary">
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
{localize('com_assistants_allow_sites_you_trust')}
</p>
</div>
)}
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowInfo((prev) => !prev)}
inProgressText={localize('com_assistants_running_action')}
authText={
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
}
finishedText={getFinishedText()}
hasInput={hasInfo}
isExpanded={showInfo}
error={cancelled}
/>
</div>
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
</Popover.Root>
<div
className="relative"
style={{
height: showInfo ? contentHeight : 0,
overflow: 'hidden',
transition:
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: showInfo ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<div
className={cn(
'overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-md',
showInfo && 'shadow-lg',
)}
style={{
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showInfo ? 1 : 0,
transition:
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<div ref={contentRef}>
{showInfo && hasInfo && (
<ToolCallInfo
key="tool-call-info"
input={args ?? ''}
output={output}
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
/>
)}
</div>
</div>
</div>
{auth != null && auth && progress < 1 && !cancelled && (
<div className="flex w-full flex-col gap-2.5">
<div className="mb-1 mt-2">
<Button
className="font-mediu inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm"
variant="default"
rel="noopener noreferrer"
onClick={() => window.open(auth, '_blank', 'noopener,noreferrer')}
>
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
</Button>
</div>
<p className="flex items-center text-xs text-text-warning">
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
{localize('com_assistants_allow_sites_you_trust')}
</p>
</div>
)}
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
</>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { useLocalize } from '~/hooks';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return (
<div
className="rounded-lg bg-surface-tertiary p-2 text-xs text-text-primary"
style={{
position: 'relative',
maxHeight,
overflow: 'auto',
}}
>
<pre className="m-0 whitespace-pre-wrap break-words" style={{ overflowWrap: 'break-word' }}>
<code>{text}</code>
</pre>
</div>
);
}
export default function ToolCallInfo({
input,
output,
domain,
function_name,
pendingAuth,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
}) {
const localize = useLocalize();
const formatText = (text: string) => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
};
let title =
domain != null && domain
? localize('com_assistants_domain_info', { 0: domain })
: localize('com_assistants_function_use', { 0: function_name });
if (pendingAuth === true) {
title =
domain != null && domain
? localize('com_assistants_action_attempt', { 0: domain })
: localize('com_assistants_attempt_info');
}
return (
<div className="w-full p-2">
<div style={{ opacity: 1 }}>
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
<div>
<OptimizedCodeBlock text={formatText(input)} maxHeight={250} />
</div>
{output && (
<>
<div className="my-2 text-sm font-medium text-text-primary">
{localize('com_ui_result')}
</div>
<div>
<OptimizedCodeBlock text={formatText(output)} maxHeight={250} />
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import * as Popover from '@radix-ui/react-popover';
import useLocalize from '~/hooks/useLocalize';
export default function ToolPopover({
input,
output,
domain,
function_name,
pendingAuth,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
}) {
const localize = useLocalize();
const formatText = (text: string) => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
};
let title =
domain != null && domain
? localize('com_assistants_domain_info', { 0: domain })
: localize('com_assistants_function_use', { 0: function_name });
if (pendingAuth === true) {
title =
domain != null && domain
? localize('com_assistants_action_attempt', { 0: domain })
: localize('com_assistants_attempt_info');
}
return (
<Popover.Portal>
<Popover.Content
side="bottom"
align="start"
sideOffset={12}
alignOffset={-5}
className="w-18 min-w-[180px] max-w-sm rounded-lg bg-surface-primary px-1"
>
<div tabIndex={-1}>
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
<code className="!whitespace-pre-wrap ">{formatText(input)}</code>
</div>
</div>
{output != null && output && (
<>
<div className="mb-2 mt-2 text-sm font-medium text-text-primary">
{localize('com_ui_result')}
</div>
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
<code className="!whitespace-pre-wrap ">{formatText(output)}</code>
</div>
</div>
</>
)}
</div>
</div>
</Popover.Content>
</Popover.Portal>
);
}

View File

@@ -23,7 +23,7 @@ const LoadingSpinner = memo(() => {
return (
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
<Spinner className="h-4 w-4 text-text-primary" />
<Spinner className="text-text-primary" />
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
</div>
);

View File

@@ -41,7 +41,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
onRename();
}}
role="button"
aria-label={isSmallScreen ? undefined : localize('com_ui_double_click_to_rename')}
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
>
{title || localize('com_ui_untitled')}
</div>

View File

@@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
import { presetSettings } from 'librechat-data-provider';
export default function AnthropicSettings({
conversation,

View File

@@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
import { presetSettings } from 'librechat-data-provider';
export default function BedrockSettings({
conversation,

View File

@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition, DynamicSettingProps } from 'librechat-data-provider';
import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
import { presetSettings } from 'librechat-data-provider';
export default function OpenAISettings({
conversation,

View File

@@ -1,19 +1,21 @@
import React from 'react';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
type ActionButtonProps = {
onClick: () => void;
};
export default function ActionButton({ onClick }: ActionButtonProps) {
const localize = useLocalize();
return (
<div className="w-32">
<Button
className="w-full rounded-md border border-black bg-white p-0 text-black hover:bg-black hover:text-white"
onClick={onClick}
>
Action Button
{/* Action Button */}
{localize('com_ui_action_button')}
</Button>
</div>
);

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { ListFilter } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import {
flexRender,
getCoreRowModel,
@@ -18,24 +19,25 @@ import { FileContext } from 'librechat-data-provider';
import type { AugmentedColumnDef } from '~/common';
import type { TFile } from 'librechat-data-provider';
import {
Button,
Input,
Table,
Button,
TableRow,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from '~/components/ui';
import ActionButton from '~/components/Files/ActionButton';
import { useDeleteFilesFromTable } from '~/hooks/Files';
import { TrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionButton from '../ActionButton';
import UploadFileButton from './UploadFileButton';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -57,12 +59,14 @@ export default function DataTableFile<TData, TValue>({
data,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const setFiles = useSetRecoilState(store.filesByIndex(0));
const [isDeleting, setIsDeleting] = React.useState(false);
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const table = useReactTable({
data,
@@ -87,7 +91,7 @@ export default function DataTableFile<TData, TValue>({
<>
<div className="mt-2 flex flex-col items-start">
<h2 className="text-lg">
<strong>Files</strong>
<strong>{localize('com_ui_files')}</strong>
</h2>
<div className="mt-3 flex w-full flex-col-reverse justify-between md:flex-row">
<div className="mt-3 flex w-full flex-row justify-center gap-x-3 md:m-0 md:justify-start">
@@ -103,7 +107,7 @@ export default function DataTableFile<TData, TValue>({
const filesToDelete = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original);
deleteFiles({ files: filesToDelete as TFile[] });
deleteFiles({ files: filesToDelete as TFile[], setFiles });
setRowSelection({});
}}
className="ml-1 gap-2 dark:hover:bg-gray-850/25 sm:ml-0"
@@ -242,13 +246,11 @@ export default function DataTableFile<TData, TValue>({
</Table>
</div>
<div className="ml-4 mr-4 mt-4 flex h-auto items-center justify-end space-x-2 py-4 sm:ml-0 sm:mr-0 sm:h-0">
<div className="text-muted-foreground ml-2 flex-1 text-sm">
{localize(
'com_files_number_selected', {
0: `${table.getFilteredSelectedRowModel().rows.length}`,
1: `${table.getFilteredRowModel().rows.length}`,
},
)}
<div className="ml-2 flex-1 text-sm text-muted-foreground">
{localize('com_files_number_selected', {
0: `${table.getFilteredSelectedRowModel().rows.length}`,
1: `${table.getFilteredRowModel().rows.length}`,
})}
</div>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"

View File

@@ -3,9 +3,9 @@ import { InfoIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import React, { useRef, useState, useMemo, useEffect } from 'react';
import type { CodeBarProps } from '~/common';
import LogContent from '~/components/Chat/Messages/Content/Parts/LogContent';
import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher';
import { useToolCallsMapContext, useMessageContext } from '~/Providers';
import { LogContent } from '~/components/Chat/Messages/Content/Parts';
import RunCode from '~/components/Messages/Content/RunCode';
import Clipboard from '~/components/svg/Clipboard';
import CheckMark from '~/components/svg/CheckMark';

View File

@@ -1,6 +1,6 @@
import debounce from 'lodash/debounce';
import { Tools, AuthType } from 'librechat-data-provider';
import { TerminalSquareIcon, Loader } from 'lucide-react';
import { TerminalSquareIcon } from 'lucide-react';
import React, { useMemo, useCallback, useEffect } from 'react';
import type { CodeBarProps } from '~/common';
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
@@ -9,6 +9,7 @@ import { useLocalize, useCodeApiKeyForm } from '~/hooks';
import { useMessageContext } from '~/Providers';
import { cn, normalizeLanguage } from '~/utils';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components';
const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex }) => {
const localize = useLocalize();
@@ -91,7 +92,7 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
disabled={execute.isLoading}
>
{execute.isLoading ? (
<Loader className="animate-spin" size={18} />
<Spinner className="animate-spin" size={18} />
) : (
<TerminalSquareIcon size={18} />
)}

View File

@@ -80,8 +80,10 @@ export const LangSelector = ({
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
{ value: 'da-DK', label: localize('com_nav_lang_danish') },
{ value: 'de-DE', label: localize('com_nav_lang_german') },
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
{ value: 'ca-ES', label: localize('com_nav_lang_catalan') },
{ value: 'et-EE', label: localize('com_nav_lang_estonian') },
{ value: 'fa-IR', label: localize('com_nav_lang_persian') },
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
@@ -94,6 +96,7 @@ export const LangSelector = ({
{ value: 'ru-RU', label: localize('com_nav_lang_russian') },
{ value: 'ja-JP', label: localize('com_nav_lang_japanese') },
{ value: 'ka-GE', label: localize('com_nav_lang_georgian') },
{ value: 'cs-CZ', label: localize('com_nav_lang_czech') },
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
{ value: 'ko-KR', label: localize('com_nav_lang_korean') },
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useWatch, useFormContext } from 'react-hook-form';
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps } from '~/common';
@@ -11,6 +10,7 @@ import DeleteButton from './DeleteButton';
import { Spinner } from '~/components';
import ShareAgent from './ShareAgent';
import { Panel } from '~/common';
import VersionButton from './Version/VersionButton';
export default function AgentFooter({
activePanel,
@@ -50,10 +50,13 @@ export default function AgentFooter({
return localize('com_ui_create');
};
const showButtons = activePanel === Panel.builder;
return (
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
<div className="mb-1 flex w-full flex-col gap-2">
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
{showButtons && agent_id && <VersionButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
{/* Context Button */}
<div className="flex items-center justify-end gap-2">
<DeleteButton
@@ -63,13 +66,13 @@ export default function AgentFooter({
/>
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
hasAccessToShareAgents && (
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
{/* Submit Button */}
<button

View File

@@ -87,7 +87,42 @@ export default function AgentPanel({
});
},
onError: (err) => {
const error = err as Error;
const error = err as Error & {
statusCode?: number;
details?: { duplicateVersion?: any; versionIndex?: number };
response?: { status?: number; data?: any };
};
const isDuplicateVersionError =
(error.statusCode === 409 && error.details?.duplicateVersion) ||
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
if (isDuplicateVersionError) {
let versionIndex: number | undefined = undefined;
if (error.details?.versionIndex !== undefined) {
versionIndex = error.details.versionIndex;
} else if (error.response?.data?.details?.versionIndex !== undefined) {
versionIndex = error.response.data.details.versionIndex;
}
if (versionIndex === undefined || versionIndex < 0) {
showToast({
message: localize('com_agents_update_error'),
status: 'error',
duration: 5000,
});
} else {
showToast({
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
status: 'error',
duration: 10000,
});
}
return;
}
showToast({
message: `${localize('com_agents_update_error')}${
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
@@ -220,7 +255,7 @@ export default function AgentPanel({
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
aria-label="Agent configuration form"
>
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
<div className="mt-2 flex w-full flex-wrap gap-2">
<div className="w-full">
<AgentSelect
createMutation={create}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { ActionsEndpoint } from '~/common';
import type { Action, TConfig, TEndpointsConfig } from 'librechat-data-provider';
import { useGetActionsQuery, useGetEndpointsQuery } from '~/data-provider';
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import VersionPanel from './Version/VersionPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
@@ -15,11 +16,19 @@ export default function AgentPanelSwitch() {
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const createMutation = useCreateAgentMutation();
const agentsConfig = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents] ?? ({} as TConfig | null),
[endpointsConfig],
);
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
if (!config) return null;
return {
...(config as TConfig),
capabilities: Array.isArray(config.capabilities)
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
: ([] as AgentCapabilities[]),
} as TAgentsEndpoint;
}, [endpointsConfig]);
useEffect(() => {
const agent_id = conversation?.agent_id ?? '';
@@ -41,12 +50,23 @@ export default function AgentPanelSwitch() {
setActivePanel,
setCurrentAgentId,
agent_id: currentAgentId,
createMutation,
};
if (activePanel === Panel.actions) {
return <ActionsPanel {...commonProps} />;
}
if (activePanel === Panel.version) {
return (
<VersionPanel
setActivePanel={setActivePanel}
agentsConfig={agentsConfig}
selectedAgentId={currentAgentId}
/>
);
}
return (
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
);

View File

@@ -105,7 +105,7 @@ export function AvatarMenu({
>
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
tabIndex={-1}
data-orientation="vertical"
onClick={onItemClick}

View File

@@ -1,16 +1,21 @@
import React, { useMemo, useEffect } from 'react';
import { ChevronLeft, RotateCcw } from 'lucide-react';
import { useFormContext, useWatch, Controller } from 'react-hook-form';
import { getSettingsKeys, alternateName } from 'librechat-data-provider';
import {
getSettingsKeys,
alternateName,
agentParamSettings,
SettingDefinition,
} from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { agentSettings } from '~/components/SidePanel/Parameters/settings';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, cn } from '~/utils';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
import keyBy from 'lodash/keyBy';
export default function ModelPanel({
setActivePanel,
@@ -52,7 +57,7 @@ export default function ModelPanel({
}
}, [provider, models, modelsData, setValue, model]);
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
const bedrockRegions = useMemo(() => {
return endpointsConfig?.[provider]?.availableRegions ?? [];
@@ -63,10 +68,18 @@ export default function ModelPanel({
[provider, endpointsConfig],
);
const parameters = useMemo(() => {
const parameters = useMemo((): SettingDefinition[] => {
const customParams = endpointsConfig[provider]?.customParams ?? {};
const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? '');
return agentSettings[combinedKey] ?? agentSettings[endpointKey];
}, [endpointType, model, provider]);
const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey;
const defaultParams =
agentParamSettings[combinedKey] ?? agentParamSettings[overriddenEndpointKey] ?? [];
const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? [];
const overriddenParamsMap = keyBy(overriddenParams, 'key');
return defaultParams.map(
(param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param,
);
}, [endpointType, endpointsConfig, model, provider]);
const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => {
setValue(`model_parameters.${optionKey}`, value);

View File

@@ -0,0 +1,26 @@
import { History } from 'lucide-react';
import { Panel } from '~/common';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface VersionButtonProps {
setActivePanel: (panel: Panel) => void;
}
const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
const localize = useLocalize();
return (
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
onClick={() => setActivePanel(Panel.version)}
>
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
{localize('com_ui_agent_version')}
</Button>
);
};
export default VersionButton;

View File

@@ -0,0 +1,68 @@
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import VersionItem from './VersionItem';
import { VersionContext } from './VersionPanel';
type VersionContentProps = {
selectedAgentId: string;
isLoading: boolean;
error: unknown;
versionContext: VersionContext;
onRestore: (index: number) => void;
};
export default function VersionContent({
selectedAgentId,
isLoading,
error,
versionContext,
onRestore,
}: VersionContentProps) {
const { versions, versionIds } = versionContext;
const localize = useLocalize();
if (!selectedAgentId) {
return (
<div className="py-8 text-center text-text-secondary">
{localize('com_ui_agent_version_no_agent')}
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
);
}
if (error) {
return (
<div className="py-8 text-center text-red-500">{localize('com_ui_agent_version_error')}</div>
);
}
if (versionIds.length > 0) {
return (
<div className="flex flex-col gap-2">
{versionIds.map(({ id, version, isActive }) => (
<VersionItem
key={id}
version={version}
index={id}
isActive={isActive}
versionsLength={versions.length}
onRestore={onRestore}
/>
))}
</div>
);
}
return (
<div className="py-8 text-center text-text-secondary">
{localize('com_ui_agent_version_empty')}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useLocalize } from '~/hooks';
import { VersionRecord } from './VersionPanel';
type VersionItemProps = {
version: VersionRecord;
index: number;
isActive: boolean;
versionsLength: number;
onRestore: (index: number) => void;
};
export default function VersionItem({
version,
index,
isActive,
versionsLength,
onRestore,
}: VersionItemProps) {
const localize = useLocalize();
const getVersionTimestamp = (version: VersionRecord): string => {
const timestamp = version.updatedAt || version.createdAt;
if (timestamp) {
try {
const date = new Date(timestamp);
if (isNaN(date.getTime()) || date.toString() === 'Invalid Date') {
return localize('com_ui_agent_version_unknown_date');
}
return date.toLocaleString();
} catch (error) {
return localize('com_ui_agent_version_unknown_date');
}
}
return localize('com_ui_agent_version_no_date');
};
return (
<div className="rounded-md border border-border-light p-3">
<div className="flex items-center justify-between font-medium">
<span>
{localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })}
</span>
{isActive && (
<span className="rounded-full border border-green-600 bg-green-600/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-500 dark:bg-green-500/30 dark:text-green-300">
{localize('com_ui_agent_version_active')}
</span>
)}
</div>
<div className="text-sm text-text-secondary">{getVersionTimestamp(version)}</div>
{!isActive && (
<button
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
onClick={() => {
if (window.confirm(localize('com_ui_agent_version_restore_confirm'))) {
onRestore(index);
}
}}
aria-label={localize('com_ui_agent_version_restore')}
>
{localize('com_ui_agent_version_restore')}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
import { ChevronLeft } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import type { AgentPanelProps } from '~/common';
import { Panel } from '~/common';
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
import { useLocalize, useToast } from '~/hooks';
import VersionContent from './VersionContent';
import { isActiveVersion } from './isActiveVersion';
export type VersionRecord = Record<string, any>;
export type AgentState = {
name: string | null;
description: string | null;
instructions: string | null;
artifacts?: string | null;
capabilities?: string[];
tools?: string[];
} | null;
export type VersionWithId = {
id: number;
originalIndex: number;
version: VersionRecord;
isActive: boolean;
};
export type VersionContext = {
versions: VersionRecord[];
versionIds: VersionWithId[];
currentAgent: AgentState;
selectedAgentId: string;
activeVersion: VersionRecord | null;
};
export interface AgentWithVersions extends Agent {
capabilities?: string[];
versions?: Array<VersionRecord>;
}
export type VersionPanelProps = {
agentsConfig: TAgentsEndpoint | null;
setActivePanel: AgentPanelProps['setActivePanel'];
selectedAgentId?: string;
};
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
const localize = useLocalize();
const { showToast } = useToast();
const {
data: agent,
isLoading,
error,
refetch,
} = useGetAgentByIdQuery(selectedAgentId, {
enabled: !!selectedAgentId && selectedAgentId !== '',
});
const revertAgentVersion = useRevertAgentVersionMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_agent_version_restore_success'),
status: 'success',
});
refetch();
},
onError: () => {
showToast({
message: localize('com_ui_agent_version_restore_error'),
status: 'error',
});
},
});
const agentWithVersions = agent as AgentWithVersions;
const currentAgent = useMemo(() => {
if (!agentWithVersions) return null;
return {
name: agentWithVersions.name,
description: agentWithVersions.description,
instructions: agentWithVersions.instructions,
artifacts: agentWithVersions.artifacts,
capabilities: agentWithVersions.capabilities,
tools: agentWithVersions.tools,
};
}, [agentWithVersions]);
const versions = useMemo(() => {
const versionsCopy = [...(agentWithVersions?.versions || [])];
return versionsCopy.sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bTime - aTime;
});
}, [agentWithVersions?.versions]);
const activeVersion = useMemo(() => {
return versions.length > 0
? versions.find((v) => isActiveVersion(v, currentAgent, versions)) || null
: null;
}, [versions, currentAgent]);
const versionIds = useMemo(() => {
if (versions.length === 0) return [];
const matchingVersions = versions.filter((v) => isActiveVersion(v, currentAgent, versions));
const activeVersionId =
matchingVersions.length > 0 ? versions.findIndex((v) => v === matchingVersions[0]) : -1;
return versions.map((version, displayIndex) => {
const originalIndex =
agentWithVersions?.versions?.findIndex(
(v) =>
v.updatedAt === version.updatedAt &&
v.createdAt === version.createdAt &&
v.name === version.name,
) ?? displayIndex;
return {
id: displayIndex,
originalIndex,
version,
isActive: displayIndex === activeVersionId,
};
});
}, [versions, currentAgent, agentWithVersions?.versions]);
const versionContext: VersionContext = useMemo(
() => ({
versions,
versionIds,
currentAgent,
selectedAgentId,
activeVersion,
}),
[versions, versionIds, currentAgent, selectedAgentId, activeVersion],
);
const handleRestore = useCallback(
(displayIndex: number) => {
const versionWithId = versionIds.find((v) => v.id === displayIndex);
if (versionWithId) {
const originalIndex = versionWithId.originalIndex;
revertAgentVersion.mutate({
agent_id: selectedAgentId,
version_index: originalIndex,
});
}
},
[revertAgentVersion, selectedAgentId, versionIds],
);
return (
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
<div className="version-panel relative flex flex-col items-center px-16 py-4 text-center">
<div className="absolute left-0 top-4">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
}}
>
<div className="version-panel-content flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
<div className="mb-2 mt-2 text-xl font-medium">
{localize('com_ui_agent_version_history')}
</div>
</div>
<div className="flex flex-col gap-4 px-2">
<VersionContent
selectedAgentId={selectedAgentId}
isLoading={isLoading}
error={error}
versionContext={versionContext}
onRestore={handleRestore}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent } from '@testing-library/react';
import VersionContent from '../VersionContent';
import { VersionContext } from '../VersionPanel';
const mockRestore = 'Restore';
jest.mock('../VersionItem', () => ({
__esModule: true,
default: jest.fn(({ version, isActive, onRestore, index }) => (
<div data-testid="version-item">
<div>{version.name}</div>
{!isActive && (
<button data-testid={`restore-button-${index}`} onClick={() => onRestore(index)}>
{mockRestore}
</button>
)}
</div>
)),
}));
jest.mock('~/hooks', () => ({
useLocalize: jest.fn().mockImplementation(() => (key) => {
const translations = {
com_ui_agent_version_no_agent: 'No agent selected',
com_ui_agent_version_error: 'Error loading versions',
com_ui_agent_version_empty: 'No versions available',
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
com_ui_agent_version_restore: 'Restore',
};
return translations[key] || key;
}),
}));
jest.mock('~/components/svg', () => ({
Spinner: () => <div data-testid="spinner" />,
}));
const mockVersionItem = jest.requireMock('../VersionItem').default;
describe('VersionContent', () => {
const mockVersionIds = [
{ id: 0, version: { name: 'First' }, isActive: true, originalIndex: 2 },
{ id: 1, version: { name: 'Second' }, isActive: false, originalIndex: 1 },
{ id: 2, version: { name: 'Third' }, isActive: false, originalIndex: 0 },
];
const mockContext: VersionContext = {
versions: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }],
versionIds: mockVersionIds,
currentAgent: { name: 'Test Agent', description: null, instructions: null },
selectedAgentId: 'agent-123',
activeVersion: { name: 'First' },
};
const defaultProps = {
selectedAgentId: 'agent-123',
isLoading: false,
error: null,
versionContext: mockContext,
onRestore: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
window.confirm = jest.fn(() => true);
});
test('renders different UI states correctly', () => {
const renderTest = (props) => {
const result = render(<VersionContent {...defaultProps} {...props} />);
return result;
};
const { getByTestId, unmount: unmount1 } = renderTest({ isLoading: true });
expect(getByTestId('spinner')).toBeInTheDocument();
unmount1();
const { getByText: getText1, unmount: unmount2 } = renderTest({
error: new Error('Test error'),
});
expect(getText1('Error loading versions')).toBeInTheDocument();
unmount2();
const { getByText: getText2, unmount: unmount3 } = renderTest({ selectedAgentId: '' });
expect(getText2('No agent selected')).toBeInTheDocument();
unmount3();
const emptyContext = { ...mockContext, versions: [], versionIds: [] };
const { getByText: getText3, unmount: unmount4 } = renderTest({ versionContext: emptyContext });
expect(getText3('No versions available')).toBeInTheDocument();
unmount4();
mockVersionItem.mockClear();
const { getAllByTestId } = renderTest({});
expect(getAllByTestId('version-item')).toHaveLength(3);
expect(mockVersionItem).toHaveBeenCalledTimes(3);
});
test('restore functionality works correctly', () => {
const onRestoreMock = jest.fn();
const { getByTestId, queryByTestId } = render(
<VersionContent {...defaultProps} onRestore={onRestoreMock} />,
);
fireEvent.click(getByTestId('restore-button-1'));
expect(onRestoreMock).toHaveBeenCalledWith(1);
expect(queryByTestId('restore-button-0')).not.toBeInTheDocument();
expect(queryByTestId('restore-button-1')).toBeInTheDocument();
expect(queryByTestId('restore-button-2')).toBeInTheDocument();
});
test('handles edge cases in data', () => {
const { getAllByTestId, getByText, queryByTestId, queryByText, rerender } = render(
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versions: [] }} />,
);
expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length);
rerender(
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versionIds: [] }} />,
);
expect(getByText('No versions available')).toBeInTheDocument();
rerender(
<VersionContent
{...defaultProps}
selectedAgentId=""
isLoading={true}
error={new Error('Test')}
/>,
);
expect(getByText('No agent selected')).toBeInTheDocument();
expect(queryByTestId('spinner')).not.toBeInTheDocument();
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
rerender(<VersionContent {...defaultProps} isLoading={true} error={new Error('Test')} />);
expect(queryByTestId('spinner')).toBeInTheDocument();
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,124 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import VersionItem from '../VersionItem';
import { VersionRecord } from '../VersionPanel';
jest.mock('~/hooks', () => ({
useLocalize: jest.fn().mockImplementation(() => (key, params) => {
const translations = {
com_ui_agent_version_title: params?.versionNumber
? `Version ${params.versionNumber}`
: 'Version',
com_ui_agent_version_active: 'Active Version',
com_ui_agent_version_restore: 'Restore',
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
com_ui_agent_version_unknown_date: 'Unknown date',
com_ui_agent_version_no_date: 'No date',
};
return translations[key] || key;
}),
}));
describe('VersionItem', () => {
const mockVersion: VersionRecord = {
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
updatedAt: '2023-01-01T00:00:00Z',
};
const defaultProps = {
version: mockVersion,
index: 1,
isActive: false,
versionsLength: 3,
onRestore: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
window.confirm = jest.fn().mockImplementation(() => true);
});
test('renders version number and timestamp', () => {
render(<VersionItem {...defaultProps} />);
expect(screen.getByText('Version 2')).toBeInTheDocument();
const date = new Date('2023-01-01T00:00:00Z').toLocaleString();
expect(screen.getByText(date)).toBeInTheDocument();
});
test('active version badge and no restore button when active', () => {
render(<VersionItem {...defaultProps} isActive={true} />);
expect(screen.getByText('Active Version')).toBeInTheDocument();
expect(screen.queryByText('Restore')).not.toBeInTheDocument();
});
test('restore button and no active badge when not active', () => {
render(<VersionItem {...defaultProps} isActive={false} />);
expect(screen.queryByText('Active Version')).not.toBeInTheDocument();
expect(screen.getByText('Restore')).toBeInTheDocument();
});
test('restore confirmation flow - confirmed', () => {
render(<VersionItem {...defaultProps} />);
fireEvent.click(screen.getByText('Restore'));
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to restore this version?');
expect(defaultProps.onRestore).toHaveBeenCalledWith(1);
});
test('restore confirmation flow - canceled', () => {
window.confirm = jest.fn().mockImplementation(() => false);
render(<VersionItem {...defaultProps} />);
fireEvent.click(screen.getByText('Restore'));
expect(window.confirm).toHaveBeenCalled();
expect(defaultProps.onRestore).not.toHaveBeenCalled();
});
test('handles invalid timestamp', () => {
render(
<VersionItem {...defaultProps} version={{ ...mockVersion, updatedAt: 'invalid-date' }} />,
);
expect(screen.getByText('Unknown date')).toBeInTheDocument();
});
test('handles missing timestamps', () => {
render(
<VersionItem
{...defaultProps}
version={{ ...mockVersion, updatedAt: undefined, createdAt: undefined }}
/>,
);
expect(screen.getByText('No date')).toBeInTheDocument();
});
test('prefers updatedAt over createdAt when both exist', () => {
const versionWithBothDates = {
...mockVersion,
updatedAt: '2023-01-02T00:00:00Z',
createdAt: '2023-01-01T00:00:00Z',
};
render(<VersionItem {...defaultProps} version={versionWithBothDates} />);
const updatedDate = new Date('2023-01-02T00:00:00Z').toLocaleString();
expect(screen.getByText(updatedDate)).toBeInTheDocument();
});
test('falls back to createdAt when updatedAt is missing', () => {
render(
<VersionItem
{...defaultProps}
version={{
...mockVersion,
updatedAt: undefined,
createdAt: '2023-01-01T00:00:00Z',
}}
/>,
);
const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString();
expect(screen.getByText(createdDate)).toBeInTheDocument();
});
test('handles empty version object', () => {
render(<VersionItem {...defaultProps} version={{}} />);
expect(screen.getByText('No date')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,194 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import { Panel } from '~/common/types';
import VersionContent from '../VersionContent';
import VersionPanel from '../VersionPanel';
const mockAgentData = {
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
versions: [
{
name: 'Version 1',
description: 'Description 1',
instructions: 'Instructions 1',
tools: ['tool1'],
capabilities: ['capability1'],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
{
name: 'Version 2',
description: 'Description 2',
instructions: 'Instructions 2',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
createdAt: '2023-01-02T00:00:00Z',
updatedAt: '2023-01-02T00:00:00Z',
},
],
};
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(() => ({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
})),
useRevertAgentVersionMutation: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
})),
}));
jest.mock('../VersionContent', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="version-content" />),
}));
jest.mock('~/hooks', () => ({
useLocalize: jest.fn().mockImplementation(() => (key) => key),
useToast: jest.fn(() => ({ showToast: jest.fn() })),
}));
describe('VersionPanel', () => {
const mockSetActivePanel = jest.fn();
const defaultProps = {
agentsConfig: null,
setActivePanel: mockSetActivePanel,
selectedAgentId: 'agent-123',
};
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAgentByIdQuery.mockReturnValue({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
});
});
test('renders panel UI and handles navigation', () => {
render(<VersionPanel {...defaultProps} />);
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
expect(screen.getByTestId('version-content')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(mockSetActivePanel).toHaveBeenCalledWith(Panel.builder);
});
test('VersionContent receives correct props', () => {
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
selectedAgentId: 'agent-123',
isLoading: false,
error: null,
versionContext: expect.objectContaining({
currentAgent: expect.any(Object),
versions: expect.any(Array),
versionIds: expect.any(Array),
}),
}),
expect.anything(),
);
});
test('handles data state variations', () => {
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({ selectedAgentId: '' }),
expect.anything(),
);
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
versionContext: expect.objectContaining({
versions: [],
versionIds: [],
currentAgent: null,
}),
}),
expect.anything(),
);
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: { ...mockAgentData, versions: undefined },
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
versionContext: expect.objectContaining({ versions: [] }),
}),
expect.anything(),
);
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: null,
isLoading: true,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({ isLoading: true }),
expect.anything(),
);
const testError = new Error('Test error');
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: testError,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({ error: testError }),
expect.anything(),
);
});
test('memoizes agent data correctly', () => {
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
versionContext: expect.objectContaining({
currentAgent: expect.objectContaining({
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
}),
versions: expect.arrayContaining([
expect.objectContaining({ name: 'Version 2' }),
expect.objectContaining({ name: 'Version 1' }),
]),
}),
}),
expect.anything(),
);
});
});

View File

@@ -0,0 +1,238 @@
import { isActiveVersion } from '../isActiveVersion';
import type { AgentState, VersionRecord } from '../VersionPanel';
describe('isActiveVersion', () => {
const createVersion = (overrides = {}): VersionRecord => ({
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
artifacts: 'default',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
...overrides,
});
const createAgentState = (overrides = {}): AgentState => ({
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
artifacts: 'default',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
...overrides,
});
test('returns true for the first version in versions array when currentAgent is null', () => {
const versions = [
createVersion({ name: 'First Version' }),
createVersion({ name: 'Second Version' }),
];
expect(isActiveVersion(versions[0], null, versions)).toBe(true);
expect(isActiveVersion(versions[1], null, versions)).toBe(false);
});
test('returns true when all fields match exactly', () => {
const version = createVersion();
const currentAgent = createAgentState();
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('returns false when names do not match', () => {
const version = createVersion();
const currentAgent = createAgentState({ name: 'Different Name' });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when descriptions do not match', () => {
const version = createVersion();
const currentAgent = createAgentState({ description: 'Different Description' });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when instructions do not match', () => {
const version = createVersion();
const currentAgent = createAgentState({ instructions: 'Different Instructions' });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when artifacts do not match', () => {
const version = createVersion();
const currentAgent = createAgentState({ artifacts: 'different_artifacts' });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('matches tools regardless of order', () => {
const version = createVersion({ tools: ['tool1', 'tool2'] });
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('returns false when tools arrays have different lengths', () => {
const version = createVersion({ tools: ['tool1', 'tool2'] });
const currentAgent = createAgentState({ tools: ['tool1', 'tool2', 'tool3'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when tools do not match', () => {
const version = createVersion({ tools: ['tool1', 'tool2'] });
const currentAgent = createAgentState({ tools: ['tool1', 'different'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('matches capabilities regardless of order', () => {
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
const currentAgent = createAgentState({ capabilities: ['capability2', 'capability1'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('returns false when capabilities arrays have different lengths', () => {
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
const currentAgent = createAgentState({
capabilities: ['capability1', 'capability2', 'capability3'],
});
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when capabilities do not match', () => {
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
const currentAgent = createAgentState({ capabilities: ['capability1', 'different'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
describe('edge cases', () => {
test('handles missing tools arrays', () => {
const version = createVersion({ tools: undefined });
const currentAgent = createAgentState({ tools: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles when version has tools but agent does not', () => {
const version = createVersion({ tools: ['tool1', 'tool2'] });
const currentAgent = createAgentState({ tools: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles when agent has tools but version does not', () => {
const version = createVersion({ tools: undefined });
const currentAgent = createAgentState({ tools: ['tool1', 'tool2'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles missing capabilities arrays', () => {
const version = createVersion({ capabilities: undefined });
const currentAgent = createAgentState({ capabilities: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles when version has capabilities but agent does not', () => {
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
const currentAgent = createAgentState({ capabilities: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles when agent has capabilities but version does not', () => {
const version = createVersion({ capabilities: undefined });
const currentAgent = createAgentState({ capabilities: ['capability1', 'capability2'] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles null values in fields', () => {
const version = createVersion({ name: null });
const currentAgent = createAgentState({ name: null });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles empty versions array', () => {
const version = createVersion();
const currentAgent = createAgentState();
const versions = [];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles empty arrays for tools', () => {
const version = createVersion({ tools: [] });
const currentAgent = createAgentState({ tools: [] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles empty arrays for capabilities', () => {
const version = createVersion({ capabilities: [] });
const currentAgent = createAgentState({ capabilities: [] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles missing artifacts field', () => {
const version = createVersion({ artifacts: undefined });
const currentAgent = createAgentState({ artifacts: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles when version has artifacts but agent does not', () => {
const version = createVersion();
const currentAgent = createAgentState({ artifacts: undefined });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles when agent has artifacts but version does not', () => {
const version = createVersion({ artifacts: undefined });
const currentAgent = createAgentState();
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('handles empty string for artifacts', () => {
const version = createVersion({ artifacts: '' });
const currentAgent = createAgentState({ artifacts: '' });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
});
});

View File

@@ -0,0 +1,59 @@
import { AgentState, VersionRecord } from './VersionPanel';
export const isActiveVersion = (
version: VersionRecord,
currentAgent: AgentState,
versions: VersionRecord[],
): boolean => {
if (!versions || versions.length === 0) {
return false;
}
if (!currentAgent) {
const versionIndex = versions.findIndex(
(v) =>
v.name === version.name &&
v.instructions === version.instructions &&
v.artifacts === version.artifacts,
);
return versionIndex === 0;
}
const matchesName = version.name === currentAgent.name;
const matchesDescription = version.description === currentAgent.description;
const matchesInstructions = version.instructions === currentAgent.instructions;
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
const toolsMatch = () => {
if (!version.tools && !currentAgent.tools) return true;
if (!version.tools || !currentAgent.tools) return false;
if (version.tools.length !== currentAgent.tools.length) return false;
const sortedVersionTools = [...version.tools].sort();
const sortedCurrentTools = [...currentAgent.tools].sort();
return sortedVersionTools.every((tool, i) => tool === sortedCurrentTools[i]);
};
const capabilitiesMatch = () => {
if (!version.capabilities && !currentAgent.capabilities) return true;
if (!version.capabilities || !currentAgent.capabilities) return false;
if (version.capabilities.length !== currentAgent.capabilities.length) return false;
const sortedVersionCapabilities = [...version.capabilities].sort();
const sortedCurrentCapabilities = [...currentAgent.capabilities].sort();
return sortedVersionCapabilities.every(
(capability, i) => capability === sortedCurrentCapabilities[i],
);
};
return (
matchesName &&
matchesDescription &&
matchesInstructions &&
matchesArtifacts &&
toolsMatch() &&
capabilitiesMatch()
);
};

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import AgentFooter from '../AgentFooter';
import { Panel } from '~/common';
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
import { SystemRoles } from 'librechat-data-provider';
import * as reactHookForm from 'react-hook-form';
import * as hooks from '~/hooks';
import type { UseMutationResult } from '@tanstack/react-query';
jest.mock('react-hook-form', () => ({
useFormContext: () => ({
control: {},
}),
useWatch: () => {
return {
agent: {
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
},
id: 'agent-123',
};
},
}));
const mockUser = {
id: 'user-123',
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: '',
role: 'USER',
provider: 'local',
emailVerified: true,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
} as TUser;
jest.mock('~/hooks', () => ({
useLocalize: () => (key) => {
const translations = {
com_ui_save: 'Save',
com_ui_create: 'Create',
};
return translations[key] || key;
},
useAuthContext: () => ({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
}),
useHasAccess: () => true,
}));
const createBaseMutation = <T = Agent, P = any>(
isLoading = false,
): UseMutationResult<T, Error, P> => {
if (isLoading) {
return {
mutate: jest.fn(),
mutateAsync: jest.fn().mockResolvedValue({} as T),
isLoading: true,
isError: false,
isSuccess: false,
isIdle: false as const,
status: 'loading' as const,
error: null,
data: undefined,
failureCount: 0,
failureReason: null,
reset: jest.fn(),
context: undefined,
variables: undefined,
isPaused: false,
};
} else {
return {
mutate: jest.fn(),
mutateAsync: jest.fn().mockResolvedValue({} as T),
isLoading: false,
isError: false,
isSuccess: false,
isIdle: true as const,
status: 'idle' as const,
error: null,
data: undefined,
failureCount: 0,
failureReason: null,
reset: jest.fn(),
context: undefined,
variables: undefined,
isPaused: false,
};
}
};
jest.mock('~/data-provider', () => ({
useUpdateAgentMutation: () => createBaseMutation<Agent, any>(),
}));
jest.mock('../Advanced/AdvancedButton', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="advanced-button" />),
}));
jest.mock('../Version/VersionButton', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="version-button" />),
}));
jest.mock('../AdminSettings', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="admin-settings" />),
}));
jest.mock('../DeleteButton', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="delete-button" />),
}));
jest.mock('../ShareAgent', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="share-agent" />),
}));
jest.mock('../DuplicateAgent', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="duplicate-agent" />),
}));
jest.mock('~/components', () => ({
Spinner: () => <div data-testid="spinner" />,
}));
describe('AgentFooter', () => {
const mockUsers = {
regular: mockUser,
admin: {
...mockUser,
id: 'admin-123',
username: 'admin',
email: 'admin@example.com',
name: 'Admin User',
role: SystemRoles.ADMIN,
} as TUser,
different: {
...mockUser,
id: 'different-user',
username: 'different',
email: 'different@example.com',
name: 'Different User',
} as TUser,
};
const createAuthContext = (user: TUser) => ({
user,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
});
const mockSetActivePanel = jest.fn();
const mockSetCurrentAgentId = jest.fn();
const mockCreateMutation = createBaseMutation<Agent, AgentCreateParams>();
const mockUpdateMutation = createBaseMutation<Agent, any>();
const defaultProps = {
activePanel: Panel.builder,
createMutation: mockCreateMutation,
updateMutation: mockUpdateMutation,
setActivePanel: mockSetActivePanel,
setCurrentAgentId: mockSetCurrentAgentId,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Main Functionality', () => {
test('renders with standard components based on default state', () => {
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByTestId('advanced-button')).toBeInTheDocument();
expect(screen.getByTestId('version-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
});
test('handles loading states for createMutation', () => {
const { unmount } = render(
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
expect(screen.queryByText('Save')).not.toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
unmount();
});
test('handles loading states for updateMutation', () => {
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
expect(screen.queryByText('Save')).not.toBeInTheDocument();
});
});
describe('Conditional Rendering', () => {
test('adjusts UI based on activePanel state', () => {
render(<AgentFooter {...defaultProps} activePanel={Panel.advanced} />);
expect(screen.queryByTestId('advanced-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
});
test('adjusts UI based on agent ID existence', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: { name: 'Test Agent', author: 'user-123' },
id: undefined,
}));
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByTestId('version-button')).toBeInTheDocument();
});
test('adjusts UI based on user role', () => {
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
jest.clearAllMocks();
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
});
test('adjusts UI based on permissions', () => {
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
test('handles null agent data', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: null,
id: 'agent-123',
}));
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { TranslationKeys, useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@@ -23,23 +23,20 @@ function DynamicCheckbox({
}: DynamicSettingProps) {
const localize = useLocalize();
const { preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<boolean>({
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
setOption,
});
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
}, [conversation, defaultValue, settingKey]);
const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setInputValue(checked);
setOption(settingKey)(checked);
};
@@ -49,8 +46,7 @@ function DynamicCheckbox({
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate: true,
setInputValue: setLocalValue,
});
return (

View File

@@ -1,5 +1,4 @@
import { useMemo, useState, useCallback } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, HoverCard, HoverCardTrigger } from '~/components/ui';
import ControlCombobox from '~/components/ui/ControlCombobox';
@@ -16,7 +15,6 @@ function DynamicCombobox({
description = '',
columnSpan,
setOption,
optionType,
options: _options,
items: _items,
showLabel = true,
@@ -36,11 +34,8 @@ function DynamicCombobox({
const [inputValue, setInputValue] = useState<string | null>(null);
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
}, [conversation, defaultValue, settingKey]);
const items = useMemo(() => {
if (_items != null) {
@@ -54,13 +49,10 @@ function DynamicCombobox({
const handleChange = useCallback(
(value: string) => {
if (optionType === OptionTypes.Custom) {
setInputValue(value);
} else {
setOption(settingKey)(value);
}
setInputValue(value);
setOption(settingKey)(value);
},
[optionType, setOption, settingKey],
[setOption, settingKey],
);
useParameterEffects({

View File

@@ -12,7 +12,6 @@ function DynamicInput({
settingKey,
defaultValue,
description = '',
type = 'string',
columnSpan,
setOption,
optionType,
@@ -28,7 +27,7 @@ function DynamicInput({
const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
setOption,
@@ -44,17 +43,7 @@ function DynamicInput({
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (type !== 'number') {
setInputValue(e);
return;
}
if (value === '') {
setInputValue(e);
} else if (!isNaN(Number(value))) {
setInputValue(e, true);
}
setInputValue(e, !isNaN(Number(e.target.value)));
};
return (

View File

@@ -33,7 +33,7 @@ function DynamicSlider({
);
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
setOption,

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