Compare commits

...

51 Commits

Author SHA1 Message Date
Marco Beretta
e1af9d21f0 Merge branch 'dev' into feat/prompt-enhancement 2025-07-10 12:11:22 +02:00
Danny Avila
4918899c8d 🖨️ fix: Use Azure Serverless API Version for Responses API (#8316) 2025-07-08 21:07:52 -04:00
Danny Avila
7e37211458 🗝️ refactor: loadServiceKey to Support Stringified JSON and Env Var Renaming (#8317)
* feat: Enhance loadServiceKey to support stringified JSON input

* chore: Update GOOGLE_SERVICE_KEY_FILE_PATH to GOOGLE_SERVICE_KEY_FILE for consistency
2025-07-08 21:07:33 -04:00
Theo N. Truong
e57fc83d40 🔧 fix: Import Path for Custom Configuration Loading (#8319) 2025-07-08 21:07:04 -04:00
Danny Avila
550610dba9 ⚖️ feat: Add Violation Scores (#8304)
- Introduced new violation scores for TTS, STT, Fork, Import, and File Upload actions in the .env.example file.
- Updated logViolation function to accept a score parameter, allowing for dynamic severity levels based on the action type.
- Modified limiters for Fork, Import, Message, STT, TTS, Tool Call, and File Upload to utilize the new violation scores when logging violations.
2025-07-07 17:08:40 -04:00
github-actions[bot]
916cd46221 🌍 i18n: Update translation.json with latest translations (#8288)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-07 17:08:15 -04:00
Dustin Healy
12b08183ff 🐛 fix: Memories Key Updates (#8302)
* Updated the PATCH /memories/:key endpoint to allow key changes while ensuring no duplicate keys exist.
* Improved error handling in MemoryCreateDialog and MemoryEditDialog for key validation and duplication scenarios.
* Added a new translation for memory key validation error in translation.json.
2025-07-07 16:38:55 -04:00
Danny Avila
f4d97e1672 📝 docs: Update README 2025-07-07 01:14:07 -04:00
Danny Avila
035fa081c1 🔧 refactor: Prevent Unnecessary Google Service Key Loading (#8287)
* 🔧 refactor: Improve Google Key Handling in `loadAsyncEndpoints`

- Enhanced logic to check if GOOGLE_KEY is provided, including user-provided checks.
- Updated service key loading mechanism to only attempt loading if GOOGLE_KEY is not provided.
- Added error logging for service key loading failures.

* 🔧 refactor: Enhance service key loading logic in `initializeClient`
2025-07-07 01:10:08 -04:00
Danny Avila
aecf8f19a6 🔧 fix: Initialize reasoningKey to 'reasoning_content' (#8286)
* chore: bump @librechat/agents to v2.4.56

* chore: bump @librechat/api version to 1.2.6

* fix: initialize reasoningKey to 'reasoning_content' in createRun function
2025-07-07 01:05:40 -04:00
Dustin Healy
35f548a94d 🔄 refactor: Google grounding field to web_search for Consistency (#8285)
- Updated the Google configuration and related schemas to replace 'grounding' with 'web_search' for consistency.
- Adjusted the logic in the getGoogleConfig function to reflect the new naming convention.
- Ensured all references in parameter settings and conversation schemas are updated accordingly.
2025-07-07 00:41:51 -04:00
Danny Avila
e60c0cf201 🔍 feat: Anthropic Web Search (#8281)
* chore: bump @librechat/agents to ^2.4.54 for anthropic web search support

* WIP: hardcoded web search tool usage

* feat: Implement web search functionality in Anthropic integration

- Updated parameters panel to include web search for anthropic models.
- Updated necessary schemas to accomodate toggle functionality

* chore: Set default web search option to false in anthropicSettings

* refactor: Rename webSearch to web_search for consistency across settings and schemas

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

---------

Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
2025-07-06 21:43:09 -04:00
github-actions[bot]
5b392f9cb0 🌍 i18n: Update translation.json with latest translations (#8255)
* 🌍 i18n: Update translation.json with latest translations

* Update translation.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-05 18:04:57 -04:00
Dustin Healy
e0f468da20 🔍 feat: Add SearXNG for Web Search and Enhance ApiKeyDialog (#8242)
* 🔍 feat: Add SearXNG Web Search support and enhance ApiKeyDialog

- Updated WebSearch component to include authentication data for web search functionality so it won't show badge after being revoked
- Refactored ApiKeyDialog to streamline provider, scraper, and reranker selection with new InputSection component
- Added support for SearXNG as a search provider and updated translation files accordingly
- Improved form handling in useAuthSearchTool to accommodate new API keys and URLs

* 📜 chore: remove unused i18next key

* 📦 chore: address comments (swap API key and URL fields in SearXNG config, change input fields to 'text' from 'password'

* 📦 chore: make URL fields go first in ApiKeyDialog

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

* ci: update webSearch configuration to include searxng fields in AppService.spec.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-05 17:58:22 -04:00
Danny Avila
91a2df4759 🔧 refactor: Change Permissions Check from some to every for Stricter Access Validation (#8270)
* 🔧 refactor: Change Permissions Check from `some` to `every` for Stricter Access Validation

* 🧪 ci: Add comprehensive tests for access middleware functions

* fix: custom provider check logic in `getProviderConfig` function
2025-07-05 15:53:08 -04:00
Danny Avila
97a99985fa 🛡️ feat: Rate Limiting for Conversation Forking (#8269)
* chore: Improve error logging for fetching conversations, and use new TS packages for utils

* feat: Implement fork limiters for conversation forking requests

* chore: error message for conversation index deletion to clarify syncing behavior

* feat: Enhance error handling for forking with rate limit message
2025-07-05 15:02:32 -04:00
Danny Avila
3554625a06 refactor: Add Robust Timestamp handling for Conversation Imports (#8262) 2025-07-05 12:44:19 -04:00
Danny Avila
a37bf6719c 🧪 refactor: Add Validation for Agent Creation/Updates (#8261)
* refactor: Add validation schemas for agent creation and updates

* fix: Ensure author validation is applied in correct order for agent update handler

* ci: Add comprehensive unit tests for agent creation and update handlers with mass assignment protection

* fix: add missing  web_search tool in system tools configuration
2025-07-05 11:34:28 -04:00
Danny Avila
e513f50c08 ⚒️ refactor: Keep useAvailableToolsQuery Enabled for All Endpoints 2025-07-04 15:43:17 -04:00
Danny Avila
f5511e4a4e 🔁 refactor: Capabilities for Tools/File handling for Direct Endpoints (#8253)
* feat: add useAgentCapabilities hook to manage agent capabilities

* refactor: move  agents and endpoints configuration to AgentPanel context provider

* refactor: implement useGetAgentsConfig hook for consolidated agents and endpoints management

* refactor: enhance ToolsDropdown to utilize agent capabilities and streamline dropdown item rendering

* chore: reorder return values in useAgentCapabilities for improved clarity

* refactor: enhance agent capabilities handling in AttachFileMenu and update file handling logic to allow capabilities to be used for non-agents endpoints
2025-07-04 14:51:26 -04:00
Danny Avila
a288ad1d9c 🪄 feat: Artifacts Badge & Optimize Ephemeral Agent State (#8252)
* 🔧 fix: Update type annotations in useEventHandlers for better type safety

* 🔧 refactor: `useToolToggle` for improved localStorage synchronization and allow string/falsy values for setting to storage

*  feat: Implement Artifacts badge to BadgeRow with toggle options and UI components

- Added Artifacts component to manage artifacts state and options.
- Introduced ArtifactsSubMenu for additional settings related to artifacts.
- Integrated artifacts functionality into BadgeRow and ToolsDropdown components.
- Updated localStorage handling for artifacts state persistence.
- Enhanced localization for artifacts-related strings in translation files.
- Refactored Agent model to include artifacts in the ephemeral agent response.

* fix: set ephemeral agent state for conversation on finalization

* chore: remove beta settings dialog tab

* refactor: improve Ephemeral Agent statefulness

* fix: update setValue parameter to use 'value' instead of 'isChecked' in CheckboxButton

* refactor: update color classes for Artifact toggle and order of dropdown components

* chore: remove unused i18n localization
2025-07-04 13:25:04 -04:00
Sebastien Bruel
458580ec87 🥅 refactor: Express App default Error Handling with ErrorController (#8249) 2025-07-04 13:24:57 -04:00
github-actions[bot]
4285d5841c 🌍 i18n: Update translation.json with latest translations (#8235)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-04 11:48:54 -04:00
Sebastien Bruel
5ee55cda4f 📦 chore: bump @modelcontextprotocol/sdk to 1.13.3 and cleanup mcp/connection.ts (#8241) 2025-07-04 09:28:57 -04:00
Danny Avila
404d40cbef 📦 chore: override @langchain/openai to v0.5.16 2025-07-03 23:16:42 -04:00
Danny Avila
f4680b016c 📦 chore: bump @librechat/agents to v2.4.51 (#8234) 2025-07-03 22:35:13 -04:00
Ruben Talstra
077224b351 feat: Add support for Armenian, Latvian, and Uyghur languages (#8227) 2025-07-03 11:16:33 -04:00
Danny Avila
9c70d1db96 🔧 fix: Include apiKey in llmConfig for Azure OpenAI Responses API 2025-07-02 13:12:05 -04:00
Danny Avila
543281da6c 🔧 fix: Tool Selection for Google Models 2025-07-02 13:01:51 -04:00
Danny Avila
24800bfbeb v0.7.9-rc1 2025-07-02 10:27:34 -04:00
Danny Avila
07e08143e4 🧠 fix: Prevent Memory Errors with Buffer String (#8196) 2025-07-02 10:25:19 -04:00
Dustin Healy
8ba61a86f4 🔍 feat: Web Search via OpenAI Responses API (#8186)
* 🔍 feat: Introduce Web Search Functionality for OpenAI API

- Added a new web_search parameter to enable web search capabilities in the OpenAI configuration.
- Updated the DynamicSlider component for improved styling.
- Enhanced the useSetIndexOptions hook to auto-enable the Responses API when web search is activated.
- Modified relevant schemas, types, and translation files to support the new web search feature.

* chore: remove comments

* refactor: tool handling in initializeAgent for better clarity and functionality and reflection of openai features

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-02 10:03:14 -04:00
Danny Avila
56ad92fb1c 🤖 feat: Azure OpenAI Responses API (#8195)
* 🤖 feat: Azure OpenAI Responses API

* chore: cleanup order of executions
2025-07-02 09:39:19 -04:00
github-actions[bot]
1ceb52d2b5 🌍 i18n: Update translation.json with latest translations (#8164)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-02 01:17:53 -04:00
Danny Avila
5d267aa8e2 🔀 fix: Assistants API File Attachments 2025-07-01 22:38:10 -04:00
Danny Avila
59d00e99f3 🔍 feat: Fetch Google Service Key and Consolidate Key Loading Logic (#8179) 2025-07-01 22:37:29 -04:00
Dustin Healy
738d04fac4 🔍 feat: Add Google Search Grounding Toggle (#8174)
*  feat: Add Google Search Grounding Feature and Update Agent Tool Initialization

- Introduced a new grounding option in the Google configuration to enable real-time web search results.
- Updated the agent initialization to concatenate additional tools from options.
- Enhanced translation files to include descriptions for the new grounding feature.
- Modified relevant schemas and parameter settings to support the grounding functionality.

* 🔑 chore: Update @librechat/agents dependency to version 2.4.50

*  fix: Ensure tools array is initialized before concatenation in initializeAgent function

* chore: Update version of librechat-data-provider to 0.7.899 and add GOOGLE_TOOL_CONFLICT error type

* fix: Adjust label class for better text wrapping in DynamicSwitch component

* fix: Handle Google tool conflict error and update error messages in translation

* fix: Restore grounding setting in googleCol2 configuration

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-01 18:00:18 -04:00
Dani Regli
8a5dbac0f9 🛂 fix: Reuse OpenID Auth Tokens with Proxy Setup (#8151)
* Fixes https://github.com/danny-avila/LibreChat/issues/8099 in correctly setting up proxy support

- fixes the openid Strategy
- fixes the openid jwt strategy (jwksRsa fetching in a proxy environment)

Signed-off-by: Regli Daniel <daniel.regli1@sanitas.com>

* Fixes https://github.com/danny-avila/LibreChat/issues/8099 in correctly setting up proxy support

- properly formatted

Signed-off-by: Regli Daniel <1daniregli@gmail.com>

---------

Signed-off-by: Regli Daniel <daniel.regli1@sanitas.com>
Signed-off-by: Regli Daniel <1daniregli@gmail.com>
Co-authored-by: schnaker85 <1daniregligmail.com>
2025-07-01 16:30:06 -04:00
Danny Avila
434289fe92 🔀 feat: Save & Submit Message Content Parts (#8171)
* 🐛 fix: Enhance provider validation and error handling in getProviderConfig function

* WIP: edit text part

* refactor: Allow updating of both TEXT and THINK content types in message updates

* WIP: first pass, save & submit

* chore: remove legacy generation user message field

* feat: merge edited content

* fix: update placeholder and description for bedrock setting

* fix: remove unsupported warning message for AI resubmission
2025-07-01 15:43:10 -04:00
Samuel Path
a648ad3d13 fix: Agent MCP Tools Checkbox Inactive When Hidden (#8166) 2025-07-01 10:05:00 -04:00
Samuel Path
55d63caaf4 💻 ci: Make Unit Tests Pass on MacOS (#8165) 2025-07-01 09:20:33 -04:00
Danny Avila
313539d1ed 🔑 refactor: Prioritize GOOGLE_KEY When GCP Service Key File Provided (#8150) 2025-06-30 18:51:50 -04:00
Danny Avila
f869d772f7 🪐 feat: Initial OpenAI Responses API Support (#8149)
* chore: update @librechat/agents to v2.4.47

* WIP: temporary auto-toggle responses api for o1/o3-pro

* feat: Enable Responses API for OpenAI models

- Updated the OpenAI client initialization to check for the useResponsesApi parameter in model options.
- Added translations for enabling the Responses API in the UI.
- Introduced useResponsesApi parameter in data provider settings and schemas.
- Updated relevant schemas to include useResponsesApi for conversation and preset configurations.

* refactor: Remove useResponsesApi check from OpenAI client initialization and update translation for Responses API

- Removed the check for useResponsesApi in the OpenAI client initialization.
- Updated the translation for enabling the Responses API to clarify its functionality.

* chore: update @librechat/agents dependency to version 2.4.48

* chore: update @librechat/agents dependency to version 2.4.49

* chore: linting

* chore: linting

* feat: Enhance DynamicSlider and validation for enumMappings

- Added support for enumMappings in DynamicSlider to display values correctly based on enum settings.
- Implemented validation for enumMappings in the generate function to ensure all options have corresponding mappings.
- Added tests for handling empty string options and incomplete enumMappings in the generate.spec.ts file.

* feat: Enhance DynamicSlider localization support

- Added localization handling for mapped values in DynamicSlider when using enumMappings.
- Updated the logic to check if the mapped value is a localization key and return the localized string if applicable.
- Adjusted dependencies in useCallback hooks to include localize for proper functionality.

* feat: Add reasoning summary and effort options to OpenAI configuration and UI

* feat: Add enumMappings for ImageDetail options in parameter settings

* style: Improve styling for DynamicSlider component labels and inputs

* chore: Update reasoning effort description and parameter order for OpenAI params

---------

Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
2025-06-30 18:34:47 -04:00
Danny Avila
20100e120b 🔑 feat: Set Google Service Key File Path (#8130) 2025-06-29 17:09:37 -04:00
Danny Avila
3f3cfefc52 🗒️ feat: Add Google Vertex AI Mistral OCR Strategy (#8125)
* Implemented new uploadGoogleVertexMistralOCR function for processing OCR using Google Vertex AI.
* Added vertexMistralOCRStrategy to handle file uploads.
* Updated FileSources and OCRStrategy enums to include vertexai_mistral_ocr.
* Introduced helper functions for JWT creation and Google service account configuration loading.
2025-06-28 13:26:03 -04:00
matt burnett
3e1591d404 🤖 fix: Remove versions and __v when Duplicating an Agent (#8115)
Revert "Add tests for agent duplication controller"

This reverts commit 3e7beb1cc336bcfe1c57411e9c151f5e6aa927e4.
2025-06-28 12:35:41 -04:00
Danny Avila
1060ae8040 🐛 fix: Assistants Endpoint Handling in createPayload Function (#8123)
* 📦 chore: bump librechat-data-provider version to 0.7.89

* 🐛 fix: Assistants endpoint handling in createPayload function
2025-06-28 12:33:43 -04:00
Marco Beretta
3d261a969d Merge branch 'dev' into feat/prompt-enhancement 2025-06-23 14:48:46 +02:00
Marco Beretta
84f62eb70c feat: Refactor FilterPrompts and List components for improved search handling and sorting functionality 2025-06-02 23:32:09 +02:00
Marco Beretta
3e698338aa feat: Implement CreatePromptButton and enhance AutoSendPrompt with dialog settings 2025-06-02 18:56:52 +02:00
Marco Beretta
0e26df0390 feat: Add user prompt preferences and favorites functionality 2025-06-02 18:56:51 +02:00
183 changed files with 8620 additions and 1690 deletions

View File

@@ -349,6 +349,11 @@ REGISTRATION_VIOLATION_SCORE=1
CONCURRENT_VIOLATION_SCORE=1
MESSAGE_VIOLATION_SCORE=1
NON_BROWSER_VIOLATION_SCORE=20
TTS_VIOLATION_SCORE=0
STT_VIOLATION_SCORE=0
FORK_VIOLATION_SCORE=0
IMPORT_VIOLATION_SCORE=0
FILE_UPLOAD_VIOLATION_SCORE=0
LOGIN_MAX=7
LOGIN_WINDOW=5

View File

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

View File

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

View File

@@ -52,7 +52,7 @@
- 🖥️ **UI & Experience** inspired by ChatGPT with enhanced design and features
- 🤖 **AI Model Selection**:
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Assistants API (incl. Azure)
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (incl. Azure)
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
@@ -66,10 +66,9 @@
- 🔦 **Agents & Tools Integration**:
- **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
- No-Code Custom Assistants: Build specialized, AI-driven helpers without coding
- Flexible & Extensible: Attach tools like DALL-E-3, file search, code execution, and more
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, and more
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
- Use LibreChat Agents and OpenAI Assistants with Files, Code Interpreter, Tools, and API Actions
- 🔍 **Web Search**:
- Search the internet and retrieve relevant information to enhance your AI context

View File

@@ -13,7 +13,6 @@ const {
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts');
const { addSpaceIfNeeded } = require('~/server/utils');
const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream');
const { logger } = require('~/config');
@@ -572,7 +571,7 @@ class BaseClient {
});
}
const { generation = '' } = opts;
const { editedContent } = opts;
// It's not necessary to push to currentMessages
// depending on subclass implementation of handling messages
@@ -587,11 +586,21 @@ class BaseClient {
isCreatedByUser: false,
model: this.modelOptions?.model ?? this.model,
sender: this.sender,
text: generation,
};
this.currentMessages.push(userMessage, latestMessage);
} else {
latestMessage.text = generation;
} else if (editedContent != null) {
// Handle editedContent for content parts
if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) {
const { index, text, type } = editedContent;
if (index >= 0 && index < latestMessage.content.length) {
const contentPart = latestMessage.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
}
}
}
}
this.continued = true;
} else {
@@ -672,16 +681,32 @@ class BaseClient {
};
if (typeof completion === 'string') {
responseMessage.text = addSpaceIfNeeded(generation) + completion;
responseMessage.text = completion;
} else if (
Array.isArray(completion) &&
(this.clientName === EModelEndpoint.agents ||
isParamEndpoint(this.options.endpoint, this.options.endpointType))
) {
responseMessage.text = '';
responseMessage.content = completion;
if (!opts.editedContent || this.currentMessages.length === 0) {
responseMessage.content = completion;
} else {
const latestMessage = this.currentMessages[this.currentMessages.length - 1];
if (!latestMessage?.content) {
responseMessage.content = completion;
} else {
const existingContent = [...latestMessage.content];
const { type: editedType } = opts.editedContent;
responseMessage.content = this.mergeEditedContent(
existingContent,
completion,
editedType,
);
}
}
} else if (Array.isArray(completion)) {
responseMessage.text = addSpaceIfNeeded(generation) + completion.join('');
responseMessage.text = completion.join('');
}
if (
@@ -1095,6 +1120,50 @@ class BaseClient {
return numTokens;
}
/**
* Merges completion content with existing content when editing TEXT or THINK types
* @param {Array} existingContent - The existing content array
* @param {Array} newCompletion - The new completion content
* @param {string} editedType - The type of content being edited
* @returns {Array} The merged content array
*/
mergeEditedContent(existingContent, newCompletion, editedType) {
if (!newCompletion.length) {
return existingContent.concat(newCompletion);
}
if (editedType !== ContentTypes.TEXT && editedType !== ContentTypes.THINK) {
return existingContent.concat(newCompletion);
}
const lastIndex = existingContent.length - 1;
const lastExisting = existingContent[lastIndex];
const firstNew = newCompletion[0];
if (lastExisting?.type !== firstNew?.type || firstNew?.type !== editedType) {
return existingContent.concat(newCompletion);
}
const mergedContent = [...existingContent];
if (editedType === ContentTypes.TEXT) {
mergedContent[lastIndex] = {
...mergedContent[lastIndex],
[ContentTypes.TEXT]:
(mergedContent[lastIndex][ContentTypes.TEXT] || '') + (firstNew[ContentTypes.TEXT] || ''),
};
} else {
mergedContent[lastIndex] = {
...mergedContent[lastIndex],
[ContentTypes.THINK]:
(mergedContent[lastIndex][ContentTypes.THINK] || '') +
(firstNew[ContentTypes.THINK] || ''),
};
}
// Add remaining completion items
return mergedContent.concat(newCompletion.slice(1));
}
async sendPayload(payload, opts = {}) {
if (opts && typeof opts === 'object') {
this.setOptions(opts);

View File

@@ -9,7 +9,7 @@ const banViolation = require('./banViolation');
* @param {Object} res - Express response object.
* @param {string} type - The type of violation.
* @param {Object} errorMessage - The error message to log.
* @param {number} [score=1] - The severity of the violation. Defaults to 1
* @param {number | string} [score=1] - The severity of the violation. Defaults to 1
*/
const logViolation = async (req, res, type, errorMessage, score = 1) => {
const userId = req.user?.id ?? req.user?._id;

View File

@@ -90,7 +90,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
}
const instructions = req.body.promptPrefix;
return {
const result = {
id: agent_id,
instructions,
provider: endpoint,
@@ -98,6 +98,11 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
model,
tools,
};
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
return result;
};
/**

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');

View File

@@ -1,7 +1,7 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.7.8",
"version": "v0.7.9-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.46",
"@librechat/agents": "^2.4.56",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",

View File

@@ -24,17 +24,23 @@ const handleValidationError = (err, res) => {
}
};
// eslint-disable-next-line no-unused-vars
module.exports = (err, req, res, next) => {
module.exports = (err, _req, res, _next) => {
try {
if (err.name === 'ValidationError') {
return (err = handleValidationError(err, res));
return handleValidationError(err, res);
}
if (err.code && err.code == 11000) {
return (err = handleDuplicateKeyError(err, res));
return handleDuplicateKeyError(err, res);
}
} catch (err) {
// Special handling for errors like SyntaxError
if (err.statusCode && err.body) {
return res.status(err.statusCode).send(err.body);
}
logger.error('ErrorController => error', err);
res.status(500).send('An unknown error occurred.');
return res.status(500).send('An unknown error occurred.');
} catch (err) {
logger.error('ErrorController => processing error', err);
return res.status(500).send('Processing error in ErrorController.');
}
};

View File

@@ -0,0 +1,241 @@
const errorController = require('./ErrorController');
const { logger } = require('~/config');
// Mock the logger
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
},
}));
describe('ErrorController', () => {
let mockReq, mockRes, mockNext;
beforeEach(() => {
mockReq = {};
mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
mockNext = jest.fn();
logger.error.mockClear();
});
describe('ValidationError handling', () => {
it('should handle ValidationError with single error', () => {
const validationError = {
name: 'ValidationError',
errors: {
email: { message: 'Email is required', path: 'email' },
},
};
errorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
messages: '["Email is required"]',
fields: '["email"]',
});
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
});
it('should handle ValidationError with multiple errors', () => {
const validationError = {
name: 'ValidationError',
errors: {
email: { message: 'Email is required', path: 'email' },
password: { message: 'Password is required', path: 'password' },
},
};
errorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
messages: '"Email is required Password is required"',
fields: '["email","password"]',
});
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
});
it('should handle ValidationError with empty errors object', () => {
const validationError = {
name: 'ValidationError',
errors: {},
};
errorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
messages: '[]',
fields: '[]',
});
});
});
describe('Duplicate key error handling', () => {
it('should handle duplicate key error (code 11000)', () => {
const duplicateKeyError = {
code: 11000,
keyValue: { email: 'test@example.com' },
};
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
messages: 'An document with that ["email"] already exists.',
fields: '["email"]',
});
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
});
it('should handle duplicate key error with multiple fields', () => {
const duplicateKeyError = {
code: 11000,
keyValue: { email: 'test@example.com', username: 'testuser' },
};
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
messages: 'An document with that ["email","username"] already exists.',
fields: '["email","username"]',
});
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
});
it('should handle error with code 11000 as string', () => {
const duplicateKeyError = {
code: '11000',
keyValue: { email: 'test@example.com' },
};
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
messages: 'An document with that ["email"] already exists.',
fields: '["email"]',
});
});
});
describe('SyntaxError handling', () => {
it('should handle errors with statusCode and body', () => {
const syntaxError = {
statusCode: 400,
body: 'Invalid JSON syntax',
};
errorController(syntaxError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
});
it('should handle errors with different statusCode and body', () => {
const customError = {
statusCode: 422,
body: { error: 'Unprocessable entity' },
};
errorController(customError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(422);
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
});
it('should handle error with statusCode but no body', () => {
const partialError = {
statusCode: 400,
};
errorController(partialError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
});
it('should handle error with body but no statusCode', () => {
const partialError = {
body: 'Some error message',
};
errorController(partialError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
});
});
describe('Unknown error handling', () => {
it('should handle unknown errors', () => {
const unknownError = new Error('Some unknown error');
errorController(unknownError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', unknownError);
});
it('should handle errors with code other than 11000', () => {
const mongoError = {
code: 11100,
message: 'Some MongoDB error',
};
errorController(mongoError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
});
it('should handle null/undefined errors', () => {
errorController(null, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
expect(logger.error).toHaveBeenCalledWith(
'ErrorController => processing error',
expect.any(Error),
);
});
});
describe('Catch block handling', () => {
beforeEach(() => {
// Restore logger mock to normal behavior for these tests
logger.error.mockRestore();
logger.error = jest.fn();
});
it('should handle errors when logger.error throws', () => {
// Create fresh mocks for this test
const freshMockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
// Mock logger to throw on the first call, succeed on the second
logger.error
.mockImplementationOnce(() => {
throw new Error('Logger error');
})
.mockImplementation(() => {});
const testError = new Error('Test error');
errorController(testError, mockReq, freshMockRes, mockNext);
expect(freshMockRes.status).toHaveBeenCalledWith(500);
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
expect(logger.error).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,195 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent } = require('~/models/Agent');
const { getActions } = require('~/models/Action');
const { nanoid } = require('nanoid');
jest.mock('~/models/Agent');
jest.mock('~/models/Action');
jest.mock('nanoid');
describe('duplicateAgent', () => {
let req, res;
beforeEach(() => {
req = {
params: { id: 'agent_123' },
user: { id: 'user_456' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
jest.clearAllMocks();
});
it('should duplicate an agent successfully', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_789',
versions: [{ name: 'Test Agent', version: 1 }],
__v: 0,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_456',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent_new_123',
author: 'user_456',
name: expect.stringContaining('Test Agent ('),
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.not.objectContaining({
versions: expect.anything(),
__v: expect.anything(),
}),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
agent: mockNewAgent,
actions: [],
});
});
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
versions: [
{
name: 'Test Agent',
versions: [{ name: 'Nested' }],
__v: 1,
},
],
__v: 2,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(mockNewAgent.versions).toHaveLength(1);
const firstVersion = mockNewAgent.versions[0];
expect(firstVersion).not.toHaveProperty('versions');
expect(firstVersion).not.toHaveProperty('__v');
expect(mockNewAgent).not.toHaveProperty('__v');
expect(res.status).toHaveBeenCalledWith(201);
});
it('should return 404 if agent not found', async () => {
getAgent.mockResolvedValue(null);
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent not found',
status: 'error',
});
});
it('should handle tool_resources.ocr correctly', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
tool_resources: {
ocr: { enabled: true, config: 'test' },
other: { should: 'not be copied' },
},
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue({ id: 'agent_new_123' });
await duplicateAgent(req, res);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
ocr: { enabled: true, config: 'test' },
},
}),
);
});
it('should handle errors gracefully', async () => {
getAgent.mockRejectedValue(new Error('Database error'));
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
});
});

View File

@@ -525,7 +525,10 @@ class AgentClient extends BaseClient {
messagesToProcess = [...messages.slice(-messageWindowSize)];
}
}
return await this.processMemory(messagesToProcess);
const bufferString = getBufferString(messagesToProcess);
const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`);
return await this.processMemory([bufferMessage]);
} catch (error) {
logger.error('Memory Agent failed to process memory', error);
}

View File

@@ -14,8 +14,11 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
text,
endpointOption,
conversationId,
isContinued = false,
editedContent = null,
parentMessageId = null,
overrideParentMessageId = null,
responseMessageId: editedResponseMessageId = null,
} = req.body;
let sender;
@@ -67,7 +70,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
handler();
}
} catch (e) {
// Ignore cleanup errors
logger.error('[AgentController] Error in cleanup handler', e);
}
}
}
@@ -155,7 +158,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
try {
res.removeListener('close', closeHandler);
} catch (e) {
// Ignore
logger.error('[AgentController] Error removing close listener', e);
}
});
@@ -163,10 +166,14 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
user: userId,
onStart,
getReqData,
isContinued,
editedContent,
conversationId,
parentMessageId,
abortController,
overrideParentMessageId,
isEdited: !!editedContent,
responseMessageId: editedResponseMessageId,
progressOptions: {
res,
},

View File

@@ -1,6 +1,8 @@
const { z } = require('zod');
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
const {
Tools,
Constants,
@@ -8,6 +10,7 @@ const {
SystemRoles,
EToolResources,
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const {
getAgent,
@@ -30,6 +33,7 @@ const { deleteFileByFilter } = require('~/models/File');
const systemTools = {
[Tools.execute_code]: true,
[Tools.file_search]: true,
[Tools.web_search]: true,
};
/**
@@ -42,9 +46,13 @@ const systemTools = {
*/
const createAgentHandler = async (req, res) => {
try {
const { tools = [], provider, name, description, instructions, model, ...agentData } = req.body;
const validatedData = agentCreateSchema.parse(req.body);
const { tools = [], ...agentData } = removeNullishValues(validatedData);
const { id: userId } = req.user;
agentData.id = `agent_${nanoid()}`;
agentData.author = userId;
agentData.tools = [];
const availableTools = await getCachedTools({ includeGlobal: true });
@@ -58,19 +66,13 @@ const createAgentHandler = async (req, res) => {
}
}
Object.assign(agentData, {
author: userId,
name,
description,
instructions,
provider,
model,
});
agentData.id = `agent_${nanoid()}`;
const agent = await createAgent(agentData);
res.status(201).json(agent);
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents] Validation error', error.errors);
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
}
logger.error('[/Agents] Error creating agent', error);
res.status(500).json({ error: error.message });
}
@@ -154,14 +156,16 @@ const getAgentHandler = async (req, res) => {
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { projectIds, removeProjectIds, ...updateData } = req.body;
const validatedData = agentUpdateSchema.parse(req.body);
const { projectIds, removeProjectIds, ...updateData } = removeNullishValues(validatedData);
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
const isAuthor = existingAgent.author.toString() === req.user.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) {
@@ -200,6 +204,11 @@ const updateAgentHandler = async (req, res) => {
return res.json(updatedAgent);
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents/:id] Validation error', error.errors);
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
}
logger.error('[/Agents/:id] Error updating Agent', error);
if (error.statusCode === 409) {
@@ -242,6 +251,8 @@ const duplicateAgentHandler = async (req, res) => {
createdAt: _createdAt,
updatedAt: _updatedAt,
tool_resources: _tool_resources = {},
versions: _versions,
__v: _v,
...cloneData
} = agent;
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {

View File

@@ -0,0 +1,659 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { agentSchema } = require('@librechat/data-schemas');
// Only mock the dependencies that are not database-related
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn().mockResolvedValue({
web_search: true,
execute_code: true,
file_search: true,
}),
}));
jest.mock('~/models/Project', () => ({
getProjectByName: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
refreshS3Url: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
filterFile: jest.fn(),
}));
jest.mock('~/models/Action', () => ({
updateAction: jest.fn(),
getActions: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/File', () => ({
deleteFileByFilter: jest.fn(),
}));
const { createAgent: createAgentHandler, updateAgent: updateAgentHandler } = require('./v1');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
*/
let Agent;
describe('Agent Controllers - Mass Assignment Protection', () => {
let mongoServer;
let mockReq;
let mockRes;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
// Reset all mocks
jest.clearAllMocks();
// Setup mock request and response objects
mockReq = {
user: {
id: new mongoose.Types.ObjectId().toString(),
role: 'USER',
},
body: {},
params: {},
app: {
locals: {
fileStrategy: 'local',
},
},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
});
describe('createAgentHandler', () => {
test('should create agent with allowed fields only', async () => {
const validData = {
name: 'Test Agent',
description: 'A test agent',
instructions: 'Be helpful',
provider: 'openai',
model: 'gpt-4',
tools: ['web_search'],
model_parameters: { temperature: 0.7 },
tool_resources: {
file_search: { file_ids: ['file1', 'file2'] },
},
};
mockReq.body = validData;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalled();
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.name).toBe('Test Agent');
expect(createdAgent.description).toBe('A test agent');
expect(createdAgent.provider).toBe('openai');
expect(createdAgent.model).toBe('gpt-4');
expect(createdAgent.author.toString()).toBe(mockReq.user.id);
expect(createdAgent.tools).toContain('web_search');
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb).toBeDefined();
expect(agentInDb.name).toBe('Test Agent');
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
});
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
const maliciousData = {
// Required fields
provider: 'openai',
model: 'gpt-4',
name: 'Malicious Agent',
// Unauthorized fields that should be stripped
author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
authorName: 'Hacker', // Should be stripped
isCollaborative: true, // Should be stripped on creation
versions: [], // Should be stripped
_id: new mongoose.Types.ObjectId(), // Should be stripped
id: 'custom_agent_id', // Should be overridden
createdAt: new Date('2020-01-01'), // Should be stripped
updatedAt: new Date('2020-01-01'), // Should be stripped
};
mockReq.body = maliciousData;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
// Verify unauthorized fields were not set
expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
expect(createdAgent.authorName).toBeUndefined();
expect(createdAgent.isCollaborative).toBeFalsy();
expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
// Verify timestamps are recent (not the malicious dates)
const createdTime = new Date(createdAgent.createdAt).getTime();
const now = Date.now();
expect(now - createdTime).toBeLessThan(5000); // Created within last 5 seconds
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
expect(agentInDb.authorName).toBeUndefined();
});
test('should validate required fields', async () => {
const invalidData = {
name: 'Missing Required Fields',
// Missing provider and model
};
mockReq.body = invalidData;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Invalid request data',
details: expect.any(Array),
}),
);
// Verify nothing was created in database
const count = await Agent.countDocuments();
expect(count).toBe(0);
});
test('should handle tool_resources validation', async () => {
const dataWithInvalidToolResources = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Tool Resources',
tool_resources: {
// Valid resources
file_search: {
file_ids: ['file1', 'file2'],
vector_store_ids: ['vs1'],
},
execute_code: {
file_ids: ['file3'],
},
// Invalid resource (should be stripped by schema)
invalid_resource: {
file_ids: ['file4'],
},
},
};
mockReq.body = dataWithInvalidToolResources;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.tool_resources).toBeDefined();
expect(createdAgent.tool_resources.file_search).toBeDefined();
expect(createdAgent.tool_resources.execute_code).toBeDefined();
expect(createdAgent.tool_resources.invalid_resource).toBeUndefined(); // Should be stripped
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
});
test('should handle avatar validation', async () => {
const dataWithAvatar = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Avatar',
avatar: {
filepath: 'https://example.com/avatar.png',
source: 's3',
},
};
mockReq.body = dataWithAvatar;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.avatar).toEqual({
filepath: 'https://example.com/avatar.png',
source: 's3',
});
});
test('should handle invalid avatar format', async () => {
const dataWithInvalidAvatar = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Invalid Avatar',
avatar: 'just-a-string', // Invalid format
};
mockReq.body = dataWithInvalidAvatar;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Invalid request data',
}),
);
});
});
describe('updateAgentHandler', () => {
let existingAgentId;
let existingAgentAuthorId;
beforeEach(async () => {
// Create an existing agent for update tests
existingAgentAuthorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Original Agent',
provider: 'openai',
model: 'gpt-3.5-turbo',
author: existingAgentAuthorId,
description: 'Original description',
isCollaborative: false,
versions: [
{
name: 'Original Agent',
provider: 'openai',
model: 'gpt-3.5-turbo',
description: 'Original description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
existingAgentId = agent.id;
});
test('should update agent with allowed fields only', async () => {
mockReq.user.id = existingAgentAuthorId.toString(); // Set as author
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Updated Agent',
description: 'Updated description',
model: 'gpt-4',
isCollaborative: true, // This IS allowed in updates
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(400);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.name).toBe('Updated Agent');
expect(updatedAgent.description).toBe('Updated description');
expect(updatedAgent.model).toBe('gpt-4');
expect(updatedAgent.isCollaborative).toBe(true);
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Updated Agent');
expect(agentInDb.isCollaborative).toBe(true);
});
test('should reject update with unauthorized fields (mass assignment protection)', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Updated Name',
// Unauthorized fields that should be stripped
author: new mongoose.Types.ObjectId().toString(), // Should not be able to change author
authorName: 'Hacker', // Should be stripped
id: 'different_agent_id', // Should be stripped
_id: new mongoose.Types.ObjectId(), // Should be stripped
versions: [], // Should be stripped
createdAt: new Date('2020-01-01'), // Should be stripped
updatedAt: new Date('2020-01-01'), // Should be stripped
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
// Verify unauthorized fields were not changed
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString()); // Should not have changed
expect(updatedAgent.authorName).toBeUndefined();
expect(updatedAgent.id).toBe(existingAgentId); // Should not have changed
expect(updatedAgent.name).toBe('Updated Name'); // Only this should have changed
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.author.toString()).toBe(existingAgentAuthorId.toString());
expect(agentInDb.id).toBe(existingAgentId);
});
test('should reject update from non-author when not collaborative', async () => {
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Unauthorized Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'You do not have permission to modify this non-collaborative agent',
});
// Verify agent was not modified in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Original Agent');
});
test('should allow update from non-author when collaborative', async () => {
// First make the agent collaborative
await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true });
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Collaborative Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.name).toBe('Collaborative Update');
// Author field should be removed for non-author
expect(updatedAgent.author).toBeUndefined();
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Collaborative Update');
});
test('should allow admin to update any agent', async () => {
const adminUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = adminUserId;
mockReq.user.role = 'ADMIN'; // Set as admin
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Admin Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.name).toBe('Admin Update');
});
test('should handle projectIds updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
const projectId1 = new mongoose.Types.ObjectId().toString();
const projectId2 = new mongoose.Types.ObjectId().toString();
mockReq.body = {
projectIds: [projectId1, projectId2],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent).toBeDefined();
// Note: updateAgentProjects requires more setup, so we just verify the handler doesn't crash
});
test('should validate tool_resources in updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
tool_resources: {
ocr: {
file_ids: ['ocr1', 'ocr2'],
},
execute_code: {
file_ids: ['img1'],
},
// Invalid tool resource
invalid_tool: {
file_ids: ['invalid'],
},
},
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tool_resources).toBeDefined();
expect(updatedAgent.tool_resources.ocr).toBeDefined();
expect(updatedAgent.tool_resources.execute_code).toBeDefined();
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
});
test('should return 404 for non-existent agent', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
mockReq.body = {
name: 'Update Non-existent',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
});
test('should handle validation errors properly', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
model_parameters: 'invalid-not-an-object', // Should be an object
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Invalid request data',
details: expect.any(Array),
}),
);
});
});
describe('Mass Assignment Attack Scenarios', () => {
test('should prevent setting system fields during creation', async () => {
const systemFields = {
provider: 'openai',
model: 'gpt-4',
name: 'System Fields Test',
// System fields that should never be settable by users
__v: 99,
_id: new mongoose.Types.ObjectId(),
versions: [
{
name: 'Fake Version',
provider: 'fake',
model: 'fake-model',
},
],
};
mockReq.body = systemFields;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
// Verify system fields were not affected
expect(createdAgent.__v).not.toBe(99);
expect(createdAgent.versions).toHaveLength(1); // Should only have the auto-created version
expect(createdAgent.versions[0].name).toBe('System Fields Test'); // From actual creation
expect(createdAgent.versions[0].provider).toBe('openai'); // From actual creation
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.__v).not.toBe(99);
});
test('should prevent privilege escalation through isCollaborative', async () => {
// Create a non-collaborative agent
const authorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
isCollaborative: false,
versions: [
{
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
// Try to make it collaborative as a different user
const attackerId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = attackerId;
mockReq.params.id = agent.id;
mockReq.body = {
isCollaborative: true, // Trying to escalate privileges
};
await updateAgentHandler(mockReq, mockRes);
// Should be rejected
expect(mockRes.status).toHaveBeenCalledWith(403);
// Verify in database that it's still not collaborative
const agentInDb = await Agent.findOne({ id: agent.id });
expect(agentInDb.isCollaborative).toBe(false);
});
test('should prevent author hijacking', async () => {
const originalAuthorId = new mongoose.Types.ObjectId();
const attackerId = new mongoose.Types.ObjectId();
// Admin creates an agent
mockReq.user.id = originalAuthorId.toString();
mockReq.user.role = 'ADMIN';
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'Admin Agent',
author: attackerId.toString(), // Trying to set different author
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
// Author should be the actual user, not the attempted value
expect(createdAgent.author.toString()).toBe(originalAuthorId.toString());
expect(createdAgent.author.toString()).not.toBe(attackerId.toString());
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.author.toString()).toBe(originalAuthorId.toString());
});
test('should strip unknown fields to prevent future vulnerabilities', async () => {
mockReq.body = {
provider: 'openai',
model: 'gpt-4',
name: 'Future Proof Test',
// Unknown fields that might be added in future
superAdminAccess: true,
bypassAllChecks: true,
internalFlag: 'secret',
futureFeature: 'exploit',
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
// Verify unknown fields were stripped
expect(createdAgent.superAdminAccess).toBeUndefined();
expect(createdAgent.bypassAllChecks).toBeUndefined();
expect(createdAgent.internalFlag).toBeUndefined();
expect(createdAgent.futureFeature).toBeUndefined();
// Also check in database
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
expect(agentInDb.superAdminAccess).toBeUndefined();
expect(agentInDb.bypassAllChecks).toBeUndefined();
expect(agentInDb.internalFlag).toBeUndefined();
expect(agentInDb.futureFeature).toBeUndefined();
});
});
});

View File

@@ -55,7 +55,6 @@ const startServer = async () => {
/* Middleware */
app.use(noIndex);
app.use(errorController);
app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(mongoSanitize());
@@ -121,6 +120,9 @@ const startServer = async () => {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
// Add the error controller one more time after all routes
app.use(errorController);
app.use((req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',

View File

@@ -1,5 +1,4 @@
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
@@ -59,6 +58,30 @@ describe('Server Configuration', () => {
expect(response.headers['pragma']).toBe('no-cache');
expect(response.headers['expires']).toBe('0');
});
it('should return 500 for unknown errors via ErrorController', async () => {
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
// Mock MongoDB operations to fail
const originalFindOne = mongoose.models.User.findOne;
const mockError = new Error('MongoDB operation failed');
mongoose.models.User.findOne = jest.fn().mockImplementation(() => {
throw mockError;
});
try {
const response = await request(app).post('/api/auth/login').send({
email: 'test@example.com',
password: 'password123',
});
expect(response.status).toBe(500);
expect(response.text).toBe('An unknown error occurred.');
} finally {
// Restore original function
mongoose.models.User.findOne = originalFindOne;
}
});
});
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely

View File

@@ -18,7 +18,6 @@ const message = 'Your account has been temporarily banned due to violations of o
* @function
* @param {Object} req - Express Request object.
* @param {Object} res - Express Response object.
* @param {String} errorMessage - Error message to be displayed in case of /api/ask or /api/edit request.
*
* @returns {Promise<Object>} - Returns a Promise which when resolved sends a response status of 403 with a specific message if request is not of api/ask or api/edit types. If it is, calls `denyRequest()` function.
*/
@@ -135,6 +134,7 @@ const checkBan = async (req, res, next = () => {}) => {
return await banResponse(req, res);
} catch (error) {
logger.error('Error in checkBan middleware:', error);
return next(error);
}
};

View File

@@ -0,0 +1,95 @@
const rateLimit = require('express-rate-limit');
const { isEnabled } = require('@librechat/api');
const { RedisStore } = require('rate-limit-redis');
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
const forkIpMax = FORK_IP_MAX;
const forkIpWindowInMinutes = forkIpWindowMs / 60000;
const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
const forkUserMax = FORK_USER_MAX;
const forkUserWindowInMinutes = forkUserWindowMs / 60000;
return {
forkIpWindowMs,
forkIpMax,
forkIpWindowInMinutes,
forkUserWindowMs,
forkUserMax,
forkUserWindowInMinutes,
forkViolationScore: FORK_VIOLATION_SCORE,
};
};
const createForkHandler = (ip = true) => {
const {
forkIpMax,
forkUserMax,
forkViolationScore,
forkIpWindowInMinutes,
forkUserWindowInMinutes,
} = getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
const errorMessage = {
type,
max: ip ? forkIpMax : forkUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, forkViolationScore);
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
};
};
const createForkLimiters = () => {
const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: forkIpWindowMs,
max: forkIpMax,
handler: createForkHandler(),
};
const userLimiterOptions = {
windowMs: forkUserWindowMs,
max: forkUserMax,
handler: createForkHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
};
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for fork rate limiters.');
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'fork_ip_limiter:',
});
const userStore = new RedisStore({
sendCommand,
prefix: 'fork_user_limiter:',
});
ipLimiterOptions.store = ipStore;
userLimiterOptions.store = userStore;
}
const forkIpLimiter = rateLimit(ipLimiterOptions);
const forkUserLimiter = rateLimit(userLimiterOptions);
return { forkIpLimiter, forkUserLimiter };
};
module.exports = { createForkLimiters };

View File

@@ -1,16 +1,17 @@
const rateLimit = require('express-rate-limit');
const { isEnabled } = require('@librechat/api');
const { RedisStore } = require('rate-limit-redis');
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const getEnvironmentVariables = () => {
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
const importIpMax = IMPORT_IP_MAX;
@@ -27,12 +28,18 @@ const getEnvironmentVariables = () => {
importUserWindowMs,
importUserMax,
importUserWindowInMinutes,
importViolationScore: IMPORT_VIOLATION_SCORE,
};
};
const createImportHandler = (ip = true) => {
const { importIpMax, importIpWindowInMinutes, importUserMax, importUserWindowInMinutes } =
getEnvironmentVariables();
const {
importIpMax,
importUserMax,
importViolationScore,
importIpWindowInMinutes,
importUserWindowInMinutes,
} = getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
@@ -43,7 +50,7 @@ const createImportHandler = (ip = true) => {
windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage);
await logViolation(req, res, type, errorMessage, importViolationScore);
res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
};
};

View File

@@ -4,6 +4,7 @@ const createSTTLimiters = require('./sttLimiters');
const loginLimiter = require('./loginLimiter');
const importLimiters = require('./importLimiters');
const uploadLimiters = require('./uploadLimiters');
const forkLimiters = require('./forkLimiters');
const registerLimiter = require('./registerLimiter');
const toolCallLimiter = require('./toolCallLimiter');
const messageLimiters = require('./messageLimiters');
@@ -14,6 +15,7 @@ module.exports = {
...uploadLimiters,
...importLimiters,
...messageLimiters,
...forkLimiters,
loginLimiter,
registerLimiter,
toolCallLimiter,

View File

@@ -11,6 +11,7 @@ const {
MESSAGE_IP_WINDOW = 1,
MESSAGE_USER_MAX = 40,
MESSAGE_USER_WINDOW = 1,
MESSAGE_VIOLATION_SCORE: score,
} = process.env;
const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
@@ -39,7 +40,7 @@ const createHandler = (ip = true) => {
windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
};
await logViolation(req, res, type, errorMessage);
await logViolation(req, res, type, errorMessage, score);
return await denyRequest(req, res, errorMessage);
};
};

View File

@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
const sttIpMax = STT_IP_MAX;
@@ -27,11 +28,12 @@ const getEnvironmentVariables = () => {
sttUserWindowMs,
sttUserMax,
sttUserWindowInMinutes,
sttViolationScore: STT_VIOLATION_SCORE,
};
};
const createSTTHandler = (ip = true) => {
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes } =
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
getEnvironmentVariables();
return async (req, res) => {
@@ -43,7 +45,7 @@ const createSTTHandler = (ip = true) => {
windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage);
await logViolation(req, res, type, errorMessage, sttViolationScore);
res.status(429).json({ message: 'Too many STT requests. Try again later' });
};
};

View File

@@ -6,6 +6,8 @@ const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
const handler = async (req, res) => {
const type = ViolationTypes.TOOL_CALL_LIMIT;
const errorMessage = {
@@ -15,7 +17,7 @@ const handler = async (req, res) => {
windowInMinutes: 1,
};
await logViolation(req, res, type, errorMessage, 0);
await logViolation(req, res, type, errorMessage, score);
res.status(429).json({ message: 'Too many tool call requests. Try again later' });
};

View File

@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
const ttsIpMax = TTS_IP_MAX;
@@ -27,11 +28,12 @@ const getEnvironmentVariables = () => {
ttsUserWindowMs,
ttsUserMax,
ttsUserWindowInMinutes,
ttsViolationScore: TTS_VIOLATION_SCORE,
};
};
const createTTSHandler = (ip = true) => {
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes } =
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
getEnvironmentVariables();
return async (req, res) => {
@@ -43,7 +45,7 @@ const createTTSHandler = (ip = true) => {
windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage);
await logViolation(req, res, type, errorMessage, ttsViolationScore);
res.status(429).json({ message: 'Too many TTS requests. Try again later' });
};
};

View File

@@ -11,6 +11,7 @@ const getEnvironmentVariables = () => {
const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
@@ -27,6 +28,7 @@ const getEnvironmentVariables = () => {
fileUploadUserWindowMs,
fileUploadUserMax,
fileUploadUserWindowInMinutes,
fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
};
};
@@ -36,6 +38,7 @@ const createFileUploadHandler = (ip = true) => {
fileUploadIpWindowInMinutes,
fileUploadUserMax,
fileUploadUserWindowInMinutes,
fileUploadViolationScore,
} = getEnvironmentVariables();
return async (req, res) => {
@@ -47,7 +50,7 @@ const createFileUploadHandler = (ip = true) => {
windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage);
await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
res.status(429).json({ message: 'Too many file upload requests. Try again later' });
};
};

View File

@@ -1,16 +1,17 @@
const multer = require('multer');
const express = require('express');
const { sleep } = require('@librechat/agents');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { deleteToolCalls } = require('~/models/ToolCall');
const { isEnabled, sleep } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
const { logger } = require('~/config');
const assistantClients = {
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
@@ -43,6 +44,7 @@ router.get('/', async (req, res) => {
});
res.status(200).json(result);
} catch (error) {
logger.error('Error fetching conversations', error);
res.status(500).json({ error: 'Error fetching conversations' });
}
});
@@ -156,6 +158,7 @@ router.post('/update', async (req, res) => {
});
const { importIpLimiter, importUserLimiter } = createImportLimiters();
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
const upload = multer({ storage: storage, fileFilter: importFileFilter });
/**
@@ -189,7 +192,7 @@ router.post(
* @param {express.Response<TForkConvoResponse>} res - Express response object.
* @returns {Promise<void>} - The response after forking the conversation.
*/
router.post('/fork', async (req, res) => {
router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
try {
/** @type {TForkConvoRequest} */
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;

View File

@@ -477,7 +477,9 @@ describe('Multer Configuration', () => {
done(new Error('Expected mkdirSync to throw an error but no error was thrown'));
} catch (error) {
// This is the expected behavior - mkdirSync throws synchronously for invalid paths
expect(error.code).toBe('EACCES');
// On Linux, this typically returns EACCES (permission denied)
// On macOS/Darwin, this returns ENOENT (no such file or directory)
expect(['EACCES', 'ENOENT']).toContain(error.code);
done();
}
});

View File

@@ -172,40 +172,68 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
/**
* PATCH /memories/:key
* Updates the value of an existing memory entry for the authenticated user.
* Body: { value: string }
* Body: { key?: string, value: string }
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
*/
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
const { key } = req.params;
const { value } = req.body || {};
const { key: urlKey } = req.params;
const { key: bodyKey, value } = req.body || {};
if (typeof value !== 'string' || value.trim() === '') {
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
}
// Use the key from the body if provided, otherwise use the key from the URL
const newKey = bodyKey || urlKey;
try {
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
const memories = await getAllUserMemories(req.user.id);
const existingMemory = memories.find((m) => m.key === key);
const existingMemory = memories.find((m) => m.key === urlKey);
if (!existingMemory) {
return res.status(404).json({ error: 'Memory not found.' });
}
const result = await setMemory({
userId: req.user.id,
key,
value,
tokenCount,
});
// If the key is changing, we need to handle it specially
if (newKey !== urlKey) {
const keyExists = memories.find((m) => m.key === newKey);
if (keyExists) {
return res.status(409).json({ error: 'Memory with this key already exists.' });
}
if (!result.ok) {
return res.status(500).json({ error: 'Failed to update memory.' });
const createResult = await createMemory({
userId: req.user.id,
key: newKey,
value,
tokenCount,
});
if (!createResult.ok) {
return res.status(500).json({ error: 'Failed to create new memory.' });
}
const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
if (!deleteResult.ok) {
return res.status(500).json({ error: 'Failed to delete old memory.' });
}
} else {
// Key is not changing, just update the value
const result = await setMemory({
userId: req.user.id,
key: newKey,
value,
tokenCount,
});
if (!result.ok) {
return res.status(500).json({ error: 'Failed to update memory.' });
}
}
const updatedMemories = await getAllUserMemories(req.user.id);
const updatedMemory = updatedMemories.find((m) => m.key === key);
const updatedMemory = updatedMemories.find((m) => m.key === newKey);
res.json({ updated: true, memory: updatedMemory });
} catch (error) {

View File

@@ -235,12 +235,13 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
return res.status(400).json({ error: 'Content part not found' });
}
if (updatedContent[index].type !== ContentTypes.TEXT) {
const currentPartType = updatedContent[index].type;
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
return res.status(400).json({ error: 'Cannot update non-text content' });
}
const oldText = updatedContent[index].text;
updatedContent[index] = { type: ContentTypes.TEXT, text };
const oldText = updatedContent[index][currentPartType];
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {

View File

@@ -16,8 +16,10 @@ const {
// updatePromptLabels,
makePromptProduction,
} = require('~/models/Prompt');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { getUserById, updateUser } = require('~/models');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
const router = express.Router();
@@ -181,6 +183,28 @@ router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) =
}
});
/**
* Route to get user's prompt preferences (favorites and rankings)
* GET /preferences
*/
router.get('/preferences', async (req, res) => {
try {
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json({
favorites: user.promptFavorites || [],
rankings: user.promptRanking || [],
});
} catch (error) {
logger.error('Error getting user preferences', error);
res.status(500).json({ message: 'Error getting user preferences' });
}
});
router.get('/:promptId', async (req, res) => {
const { promptId } = req.params;
const author = req.user.id;
@@ -251,4 +275,79 @@ const deletePromptGroupController = async (req, res) => {
router.delete('/:promptId', checkPromptCreate, deletePromptController);
router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController);
/**
* Route to toggle favorite status for a prompt group
* POST /favorites/:groupId
*/
router.post('/favorites/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const favorites = user.promptFavorites || [];
const isFavorite = favorites.some((id) => id.toString() === groupId.toString());
let updatedFavorites;
if (isFavorite) {
updatedFavorites = favorites.filter((id) => id.toString() !== groupId.toString());
} else {
updatedFavorites = [...favorites, groupId];
}
await updateUser(req.user.id, { promptFavorites: updatedFavorites });
const response = {
promptGroupId: groupId,
isFavorite: !isFavorite,
};
res.json(response);
} catch (error) {
logger.error('Error toggling favorite status', error);
res.status(500).json({ message: 'Error updating favorite status' });
}
});
/**
* Route to update prompt group rankings
* PUT /rankings
*/
router.put('/rankings', async (req, res) => {
try {
const { rankings } = req.body;
if (!Array.isArray(rankings)) {
return res.status(400).json({ message: 'Rankings must be an array' });
}
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const promptRanking = rankings
.filter(({ promptGroupId, order }) => promptGroupId && !isNaN(parseInt(order, 10)))
.map(({ promptGroupId, order }) => ({
promptGroupId,
order: parseInt(order, 10),
}));
const updatedUser = await updateUser(req.user.id, { promptRanking });
res.json({
message: 'Rankings updated successfully',
rankings: updatedUser?.promptRanking || [],
});
} catch (error) {
logger.error('Error updating rankings', error);
res.status(500).json({ message: 'Error updating rankings' });
}
});
module.exports = router;

View File

@@ -152,12 +152,14 @@ describe('AppService', () => {
filteredTools: undefined,
includedTools: undefined,
webSearch: {
safeSearch: 1,
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}',
serperApiKey: '${SERPER_API_KEY}',
searxngApiKey: '${SEARXNG_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}',
safeSearch: 1,
serperApiKey: '${SERPER_API_KEY}',
searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}',
},
memory: undefined,
agents: {

View File

@@ -1,4 +1,10 @@
const { CacheKeys, EModelEndpoint, orderEndpointsConfig } = require('librechat-data-provider');
const {
CacheKeys,
EModelEndpoint,
isAgentsEndpoint,
orderEndpointsConfig,
defaultAgentCapabilities,
} = require('librechat-data-provider');
const loadDefaultEndpointsConfig = require('./loadDefaultEConfig');
const loadConfigEndpoints = require('./loadConfigEndpoints');
const getLogStores = require('~/cache/getLogStores');
@@ -80,8 +86,12 @@ async function getEndpointsConfig(req) {
* @returns {Promise<boolean>}
*/
const checkCapability = async (req, capability) => {
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
const endpointsConfig = await getEndpointsConfig(req);
const capabilities = endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [];
const capabilities =
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null
? (endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [])
: defaultAgentCapabilities;
return capabilities.includes(capability);
};

View File

@@ -1,5 +1,7 @@
const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { loadServiceKey, isUserProvided } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided } = require('~/server/utils');
const { config } = require('./EndpointService');
const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config;
@@ -9,37 +11,41 @@ const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, go
* @param {Express.Request} req - The request object
*/
async function loadAsyncEndpoints(req) {
let i = 0;
let serviceKey, googleUserProvides;
try {
serviceKey = require('~/data/auth.json');
} catch (e) {
if (i === 0) {
i++;
/** Check if GOOGLE_KEY is provided at all(including 'user_provided') */
const isGoogleKeyProvided = googleKey && googleKey.trim() !== '';
if (isGoogleKeyProvided) {
/** If GOOGLE_KEY is provided, check if it's user_provided */
googleUserProvides = isUserProvided(googleKey);
} else {
/** Only attempt to load service key if GOOGLE_KEY is not provided */
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json');
try {
serviceKey = await loadServiceKey(serviceKeyPath);
} catch (error) {
logger.error('Error loading service key', error);
serviceKey = null;
}
}
if (isUserProvided(googleKey)) {
googleUserProvides = true;
if (i <= 1) {
i++;
}
}
const google = serviceKey || googleKey ? { userProvide: googleUserProvides } : false;
const google = serviceKey || isGoogleKeyProvided ? { userProvide: googleUserProvides } : false;
const useAzure = req.app.locals[EModelEndpoint.azureOpenAI]?.plugins;
const gptPlugins =
useAzure || openAIApiKey || azureOpenAIApiKey
? {
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
config[EModelEndpoint.azureOpenAI]?.userProvideURL,
azure: useAzurePlugins || useAzure,
}
azure: useAzurePlugins || useAzure,
}
: false;
return { google, gptPlugins };

View File

@@ -85,7 +85,7 @@ const initializeAgent = async ({
});
const provider = agent.provider;
const { tools, toolContextMap } =
const { tools: structuredTools, toolContextMap } =
(await loadTools?.({
req,
res,
@@ -140,6 +140,24 @@ const initializeAgent = async ({
agent.provider = options.provider;
}
/** @type {import('@librechat/agents').GenericTool[]} */
let tools = options.tools?.length ? options.tools : structuredTools;
if (
(agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
options.tools?.length &&
structuredTools?.length
) {
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
} else if (
(agent.provider === Providers.OPENAI ||
agent.provider === Providers.AZURE ||
agent.provider === Providers.ANTHROPIC) &&
options.tools?.length &&
structuredTools?.length
) {
tools = structuredTools.concat(options.tools);
}
/** @type {import('@librechat/agents').ClientOptions} */
agent.model_parameters = { ...options.llmConfig };
if (options.configOptions) {
@@ -162,10 +180,10 @@ const initializeAgent = async ({
return {
...agent,
tools,
attachments,
resendFiles,
toolContextMap,
tools,
maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9,
};
};

View File

@@ -78,7 +78,17 @@ function getLLMConfig(apiKey, options = {}) {
requestOptions.anthropicApiUrl = options.reverseProxyUrl;
}
const tools = [];
if (mergedOptions.web_search) {
tools.push({
type: 'web_search_20250305',
name: 'web_search',
});
}
return {
tools,
/** @type {AnthropicClientOptions} */
llmConfig: removeNullishValues(requestOptions),
};

View File

@@ -1,5 +1,6 @@
const { getGoogleConfig, isEnabled } = require('@librechat/api');
const path = require('path');
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
const { getGoogleConfig, isEnabled, loadServiceKey } = require('@librechat/api');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { GoogleClient } = require('~/app');
@@ -15,10 +16,25 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
}
let serviceKey = {};
try {
serviceKey = require('~/data/auth.json');
} catch (_e) {
// Do nothing
/** Check if GOOGLE_KEY is provided at all (including 'user_provided') */
const isGoogleKeyProvided =
(GOOGLE_KEY && GOOGLE_KEY.trim() !== '') || (isUserProvided && userKey != null);
if (!isGoogleKeyProvided) {
/** Only attempt to load service key if GOOGLE_KEY is not provided */
try {
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE ||
path.join(__dirname, '../../../..', 'data', 'auth.json');
serviceKey = await loadServiceKey(serviceKeyPath);
if (!serviceKey) {
serviceKey = {};
}
} catch (_e) {
// Service key loading failed, but that's okay if not required
serviceKey = {};
}
}
const credentials = isUserProvided

View File

@@ -7,6 +7,16 @@ const initCustom = require('~/server/services/Endpoints/custom/initialize');
const initGoogle = require('~/server/services/Endpoints/google/initialize');
const { getCustomEndpointConfig } = require('~/server/services/Config');
/** Check if the provider is a known custom provider
* @param {string | undefined} [provider] - The provider string
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
*/
function isKnownCustomProvider(provider) {
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
provider?.toLowerCase() || '',
);
}
const providerConfigMap = {
[Providers.XAI]: initCustom,
[Providers.OLLAMA]: initCustom,
@@ -46,6 +56,13 @@ async function getProviderConfig(provider) {
overrideProvider = Providers.OPENAI;
}
if (isKnownCustomProvider(overrideProvider || provider) && !customEndpointConfig) {
customEndpointConfig = await getCustomEndpointConfig(provider);
if (!customEndpointConfig) {
throw new Error(`Provider ${provider} not supported`);
}
}
return {
getOptions,
overrideProvider,

View File

@@ -1,5 +1,9 @@
const { FileSources } = require('librechat-data-provider');
const { uploadMistralOCR, uploadAzureMistralOCR } = require('@librechat/api');
const {
uploadMistralOCR,
uploadAzureMistralOCR,
uploadGoogleVertexMistralOCR,
} = require('@librechat/api');
const {
getFirebaseURL,
prepareImageURL,
@@ -222,6 +226,26 @@ const azureMistralOCRStrategy = () => ({
handleFileUpload: uploadAzureMistralOCR,
});
const vertexMistralOCRStrategy = () => ({
/** @type {typeof saveFileFromURL | null} */
saveURL: null,
/** @type {typeof getLocalFileURL | null} */
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
prepareImagePayload: null,
/** @type {typeof deleteLocalFile | null} */
deleteFile: null,
/** @type {typeof getLocalFileStream | null} */
getDownloadStream: null,
handleFileUpload: uploadGoogleVertexMistralOCR,
});
// Strategy Selector
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) {
@@ -244,6 +268,8 @@ const getStrategyFunctions = (fileSource) => {
return mistralOCRStrategy();
} else if (fileSource === FileSources.azure_mistral_ocr) {
return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy();
} else {
throw new Error('Invalid file source');
}

View File

@@ -0,0 +1,280 @@
const { Constants } = require('librechat-data-provider');
const { ImportBatchBuilder } = require('./importBatchBuilder');
const { getImporter } = require('./importers');
// Mock the database methods
jest.mock('~/models/Conversation', () => ({
bulkSaveConvos: jest.fn(),
}));
jest.mock('~/models/Message', () => ({
bulkSaveMessages: jest.fn(),
}));
jest.mock('~/cache/getLogStores');
const getLogStores = require('~/cache/getLogStores');
const mockedCacheGet = jest.fn();
getLogStores.mockImplementation(() => ({
get: mockedCacheGet,
}));
describe('Import Timestamp Ordering', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedCacheGet.mockResolvedValue(null);
});
describe('LibreChat Import - Timestamp Issues', () => {
test('should maintain proper timestamp order between parent and child messages', async () => {
// Create a LibreChat export with out-of-order timestamps
const jsonData = {
conversationId: 'test-convo-123',
title: 'Test Conversation',
messages: [
{
messageId: 'parent-1',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
},
{
messageId: 'child-1',
parentMessageId: 'parent-1',
text: 'Child Message',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
},
{
messageId: 'grandchild-1',
parentMessageId: 'child-1',
text: 'Grandchild Message',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Even earlier
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
// Check the actual messages stored in the builder
const savedMessages = importBatchBuilder.messages;
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
// Verify all messages were found
expect(parent).toBeDefined();
expect(child).toBeDefined();
expect(grandchild).toBeDefined();
// FIXED behavior: timestamps ARE corrected
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
new Date(child.createdAt).getTime(),
);
});
test('should handle complex multi-branch scenario with out-of-order timestamps', async () => {
const jsonData = {
conversationId: 'complex-test-123',
title: 'Complex Test',
messages: [
// Branch 1: Root -> A -> B with reversed timestamps
{
messageId: 'root-1',
parentMessageId: Constants.NO_PARENT,
text: 'Root 1',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:03:00Z',
},
{
messageId: 'a-1',
parentMessageId: 'root-1',
text: 'A1',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:02:00Z', // Before parent
},
{
messageId: 'b-1',
parentMessageId: 'a-1',
text: 'B1',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:01:00Z', // Before grandparent
},
// Branch 2: Root -> C -> D with mixed timestamps
{
messageId: 'root-2',
parentMessageId: Constants.NO_PARENT,
text: 'Root 2',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Earlier than branch 1
},
{
messageId: 'c-2',
parentMessageId: 'root-2',
text: 'C2',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:04:00Z', // Much later
},
{
messageId: 'd-2',
parentMessageId: 'c-2',
text: 'D2',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:30Z', // Between root and parent
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
// Verify that timestamps are preserved as-is (not corrected)
const root1 = savedMessages.find((msg) => msg.text === 'Root 1');
const a1 = savedMessages.find((msg) => msg.text === 'A1');
const b1 = savedMessages.find((msg) => msg.text === 'B1');
const root2 = savedMessages.find((msg) => msg.text === 'Root 2');
const c2 = savedMessages.find((msg) => msg.text === 'C2');
const d2 = savedMessages.find((msg) => msg.text === 'D2');
// Branch 1: timestamps should now be in correct order
expect(new Date(a1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime());
expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(a1.createdAt).getTime());
// Branch 2: all timestamps should be properly ordered
expect(new Date(c2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime());
expect(new Date(d2.createdAt).getTime()).toBeGreaterThan(new Date(c2.createdAt).getTime());
});
test('recursive format should NOW have timestamp protection', async () => {
// Create a recursive LibreChat export with out-of-order timestamps
const jsonData = {
conversationId: 'recursive-test-123',
title: 'Recursive Test',
recursive: true,
messages: [
{
messageId: 'parent-1',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
sender: 'User',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
children: [
{
messageId: 'child-1',
parentMessageId: 'parent-1',
text: 'Child Message',
sender: 'Assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
children: [
{
messageId: 'grandchild-1',
parentMessageId: 'child-1',
text: 'Grandchild Message',
sender: 'User',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Even earlier
children: [],
},
],
},
],
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
// Messages should be saved
expect(savedMessages).toHaveLength(3);
// In recursive format, timestamps are NOT included in the saved messages
// The saveMessage method doesn't receive createdAt for recursive imports
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
expect(parent).toBeDefined();
expect(child).toBeDefined();
expect(grandchild).toBeDefined();
// Recursive imports NOW preserve and correct timestamps
expect(parent.createdAt).toBeDefined();
expect(child.createdAt).toBeDefined();
expect(grandchild.createdAt).toBeDefined();
// Timestamps should be corrected to maintain proper order
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
new Date(child.createdAt).getTime(),
);
});
});
describe('Comparison with Fork Functionality', () => {
test('fork functionality correctly handles timestamp issues (for comparison)', async () => {
const { cloneMessagesWithTimestamps } = require('./fork');
const messagesToClone = [
{
messageId: 'parent',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
},
{
messageId: 'child',
parentMessageId: 'parent',
text: 'Child Message',
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
},
];
const importBatchBuilder = new ImportBatchBuilder('user-123');
jest.spyOn(importBatchBuilder, 'saveMessage');
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
// Fork functionality DOES correct the timestamps
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
});
});
});

View File

@@ -1,6 +1,7 @@
const { v4: uuidv4 } = require('uuid');
const { EModelEndpoint, Constants, openAISettings, CacheKeys } = require('librechat-data-provider');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { cloneMessagesWithTimestamps } = require('./fork');
const getLogStores = require('~/cache/getLogStores');
const logger = require('~/config/winston');
@@ -107,67 +108,47 @@ async function importLibreChatConvo(
if (jsonData.recursive) {
/**
* Recursively traverse the messages tree and save each message to the database.
* Flatten the recursive message tree into a flat array
* @param {TMessage[]} messages
* @param {string} parentMessageId
* @param {TMessage[]} flatMessages
*/
const traverseMessages = async (messages, parentMessageId = null) => {
const flattenMessages = (
messages,
parentMessageId = Constants.NO_PARENT,
flatMessages = [],
) => {
for (const message of messages) {
if (!message.text && !message.content) {
continue;
}
let savedMessage;
if (message.sender?.toLowerCase() === 'user' || message.isCreatedByUser) {
savedMessage = await importBatchBuilder.saveMessage({
text: message.text,
content: message.content,
sender: 'user',
isCreatedByUser: true,
parentMessageId: parentMessageId,
});
} else {
savedMessage = await importBatchBuilder.saveMessage({
text: message.text,
content: message.content,
sender: message.sender,
isCreatedByUser: false,
model: options.model,
parentMessageId: parentMessageId,
});
}
const flatMessage = {
...message,
parentMessageId: parentMessageId,
children: undefined, // Remove children from flat structure
};
flatMessages.push(flatMessage);
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
if (message.children && message.children.length > 0) {
await traverseMessages(message.children, savedMessage.messageId);
flattenMessages(message.children, message.messageId, flatMessages);
}
}
return flatMessages;
};
await traverseMessages(messagesToImport);
const flatMessages = flattenMessages(messagesToImport);
cloneMessagesWithTimestamps(flatMessages, importBatchBuilder);
} else if (messagesToImport) {
const idMapping = new Map();
cloneMessagesWithTimestamps(messagesToImport, importBatchBuilder);
for (const message of messagesToImport) {
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
const newMessageId = uuidv4();
idMapping.set(message.messageId, newMessageId);
const clonedMessage = {
...message,
messageId: newMessageId,
parentMessageId:
message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT
? idMapping.get(message.parentMessageId) || Constants.NO_PARENT
: Constants.NO_PARENT,
};
importBatchBuilder.saveMessage(clonedMessage);
}
} else {
throw new Error('Invalid LibreChat file format');

View File

@@ -175,36 +175,60 @@ describe('importLibreChatConvo', () => {
jest.spyOn(importBatchBuilder, 'saveMessage');
jest.spyOn(importBatchBuilder, 'saveBatch');
// When
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
// Create a map to track original message IDs to new UUIDs
const idToUUIDMap = new Map();
importBatchBuilder.saveMessage.mock.calls.forEach((call) => {
const message = call[0];
idToUUIDMap.set(message.originalMessageId, message.messageId);
// Get the imported messages
const messages = importBatchBuilder.messages;
expect(messages.length).toBeGreaterThan(0);
// Build maps for verification
const textToMessageMap = new Map();
const messageIdToMessage = new Map();
messages.forEach((msg) => {
if (msg.text) {
// For recursive imports, text might be very long, so just use the first 100 chars as key
const textKey = msg.text.substring(0, 100);
textToMessageMap.set(textKey, msg);
}
messageIdToMessage.set(msg.messageId, msg);
});
const checkChildren = (children, parentId) => {
children.forEach((child) => {
const childUUID = idToUUIDMap.get(child.messageId);
const expectedParentId = idToUUIDMap.get(parentId) ?? null;
const messageCall = importBatchBuilder.saveMessage.mock.calls.find(
(call) => call[0].messageId === childUUID,
);
const actualParentId = messageCall[0].parentMessageId;
expect(actualParentId).toBe(expectedParentId);
if (child.children && child.children.length > 0) {
checkChildren(child.children, child.messageId);
// Count expected messages from the tree
const countMessagesInTree = (nodes) => {
let count = 0;
nodes.forEach((node) => {
if (node.text || node.content) {
count++;
}
if (node.children && node.children.length > 0) {
count += countMessagesInTree(node.children);
}
});
return count;
};
// Start hierarchy validation from root messages
checkChildren(jsonData.messages, null);
const expectedMessageCount = countMessagesInTree(jsonData.messages);
expect(messages.length).toBe(expectedMessageCount);
// Verify all messages have valid parent relationships
messages.forEach((msg) => {
if (msg.parentMessageId !== Constants.NO_PARENT) {
const parent = messageIdToMessage.get(msg.parentMessageId);
expect(parent).toBeDefined();
// Verify timestamp ordering
if (msg.createdAt && parent.createdAt) {
expect(new Date(msg.createdAt).getTime()).toBeGreaterThanOrEqual(
new Date(parent.createdAt).getTime(),
);
}
}
});
// Verify at least one root message exists
const rootMessages = messages.filter((msg) => msg.parentMessageId === Constants.NO_PARENT);
expect(rootMessages.length).toBeGreaterThan(0);
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
});

View File

@@ -1,4 +1,5 @@
const { SystemRoles } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { updateUser, findUser } = require('~/models');
const { logger } = require('~/config');
@@ -13,17 +14,23 @@ const { isEnabled } = require('~/server/utils');
* The strategy extracts the JWT from the Authorization header as a Bearer token.
* The JWT is then verified using the signing key, and the user is retrieved from the database.
*/
const openIdJwtLogin = (openIdConfig) =>
new JwtStrategy(
const openIdJwtLogin = (openIdConfig) => {
let jwksRsaOptions = {
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
: 60000,
jwksUri: openIdConfig.serverMetadata().jwks_uri,
};
if (process.env.PROXY) {
jwksRsaOptions.requestAgent = new HttpsProxyAgent(process.env.PROXY);
}
return new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
: 60000,
jwksUri: openIdConfig.serverMetadata().jwks_uri,
}),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
},
async (payload, done) => {
try {
@@ -48,5 +55,6 @@ const openIdJwtLogin = (openIdConfig) =>
}
},
);
};
module.exports = openIdJwtLogin;

View File

@@ -49,7 +49,7 @@ async function customFetch(url, options) {
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
fetchOptions = {
...options,
dispatcher: new HttpsProxyAgent(process.env.PROXY),
dispatcher: new undici.ProxyAgent(process.env.PROXY),
};
}

View File

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

View File

@@ -1,9 +1,9 @@
import React, { createContext, useContext, useState } from 'react';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider';
import type { AgentPanelContextType } from '~/common';
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { useLocalize, useGetAgentsConfig } from '~/hooks';
import { Panel } from '~/common';
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
@@ -75,21 +75,25 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
const value = {
action,
setAction,
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const value: AgentPanelContextType = {
mcp,
setMcp,
mcps,
setMcps,
activePanel,
setActivePanel,
setCurrentAgentId,
agent_id,
groupedTools,
/** Query data for actions and tools */
actions,
tools,
action,
setMcp,
actions,
setMcps,
agent_id,
setAction,
activePanel,
groupedTools,
agentsConfig,
setActivePanel,
endpointsConfig,
setCurrentAgentId,
};
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View File

@@ -1,14 +1,25 @@
import React, { createContext, useContext } from 'react';
import { Tools, LocalStorageKeys } from 'librechat-data-provider';
import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import {
useSearchApiKeyForm,
useGetAgentsConfig,
useCodeApiKeyForm,
useToolToggle,
useMCPSelect,
} from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
interface BadgeRowContextType {
conversationId?: string | null;
agentsConfig?: TAgentsEndpoint | null;
mcpSelect: ReturnType<typeof useMCPSelect>;
webSearch: ReturnType<typeof useToolToggle>;
codeInterpreter: ReturnType<typeof useToolToggle>;
artifacts: ReturnType<typeof useToolToggle>;
fileSearch: ReturnType<typeof useToolToggle>;
codeInterpreter: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
@@ -26,10 +37,88 @@ export function useBadgeRowContext() {
interface BadgeRowProviderProps {
children: React.ReactNode;
isSubmitting?: boolean;
conversationId?: string | null;
}
export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) {
export default function BadgeRowProvider({
children,
isSubmitting,
conversationId,
}: BadgeRowProviderProps) {
const hasInitializedRef = useRef(false);
const lastKeyRef = useRef<string>('');
const { agentsConfig } = useGetAgentsConfig();
const key = conversationId ?? Constants.NEW_CONVO;
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
useEffect(() => {
if (isSubmitting) {
return;
}
// Check if this is a new conversation or the first load
if (!hasInitializedRef.current || lastKeyRef.current !== key) {
hasInitializedRef.current = true;
lastKeyRef.current = key;
// Load all localStorage values
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
const codeToggleValue = localStorage.getItem(codeToggleKey);
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey);
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey);
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey);
const initialValues: Record<string, any> = {};
if (codeToggleValue !== null) {
try {
initialValues[Tools.execute_code] = JSON.parse(codeToggleValue);
} catch (e) {
console.error('Failed to parse code toggle value:', e);
}
}
if (webSearchToggleValue !== null) {
try {
initialValues[Tools.web_search] = JSON.parse(webSearchToggleValue);
} catch (e) {
console.error('Failed to parse web search toggle value:', e);
}
}
if (fileSearchToggleValue !== null) {
try {
initialValues[Tools.file_search] = JSON.parse(fileSearchToggleValue);
} catch (e) {
console.error('Failed to parse file search toggle value:', e);
}
}
if (artifactsToggleValue !== null) {
try {
initialValues[AgentCapabilities.artifacts] = JSON.parse(artifactsToggleValue);
} catch (e) {
console.error('Failed to parse artifacts toggle value:', e);
}
}
// Always set values for all tools (use defaults if not in localStorage)
// If ephemeralAgent is null, create a new object with just our tool values
setEphemeralAgent((prev) => ({
...(prev || {}),
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
[Tools.web_search]: initialValues[Tools.web_search] ?? false,
[Tools.file_search]: initialValues[Tools.file_search] ?? false,
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
}));
}
}, [key, isSubmitting, setEphemeralAgent]);
/** Startup config */
const { data: startupConfig } = useGetStartupConfig();
@@ -74,10 +163,20 @@ export default function BadgeRowProvider({ children, conversationId }: BadgeRowP
isAuthenticated: true,
});
/** Artifacts hook - using a custom key since it's not a Tool but a capability */
const artifacts = useToolToggle({
conversationId,
toolKey: AgentCapabilities.artifacts,
localStorageKey: LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
isAuthenticated: true,
});
const value: BadgeRowContextType = {
mcpSelect,
webSearch,
artifacts,
fileSearch,
agentsConfig,
startupConfig,
conversationId,
codeApiKeyForm,

View File

@@ -206,9 +206,7 @@ export type AgentPanelProps = {
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
endpointsConfig?: t.TEndpointsConfig;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agentsConfig?: t.TAgentsEndpoint | null;
};
export type AgentPanelContextType = {
@@ -225,6 +223,8 @@ export type AgentPanelContextType = {
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
agent_id?: string;
agentsConfig?: t.TAgentsEndpoint | null;
endpointsConfig?: t.TEndpointsConfig | null;
};
export type AgentModelPanelProps = {
@@ -336,6 +336,11 @@ export type TAskProps = {
export type TOptions = {
editedMessageId?: string | null;
editedText?: string | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
};
isRegenerate?: boolean;
isContinued?: boolean;
isEdited?: boolean;

View File

@@ -0,0 +1,152 @@
import React, { memo, useState, useCallback, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { ArtifactModes } from 'librechat-data-provider';
import { WandSparkles, ChevronDown } from 'lucide-react';
import CheckboxButton from '~/components/ui/CheckboxButton';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ArtifactsToggleState {
enabled: boolean;
mode: string;
}
function Artifacts() {
const localize = useLocalize();
const { artifacts } = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = artifacts;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const currentState = useMemo<ArtifactsToggleState>(() => {
if (typeof toggleState === 'string' && toggleState) {
return { enabled: true, mode: toggleState };
}
return { enabled: false, mode: '' };
}, [toggleState]);
const isEnabled = currentState.enabled;
const isShadcnEnabled = currentState.mode === ArtifactModes.SHADCNUI;
const isCustomEnabled = currentState.mode === ArtifactModes.CUSTOM;
const handleToggle = useCallback(() => {
if (isEnabled) {
debouncedChange({ value: '' });
} else {
debouncedChange({ value: ArtifactModes.DEFAULT });
}
}, [isEnabled, debouncedChange]);
const handleShadcnToggle = useCallback(() => {
if (isShadcnEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [isShadcnEnabled, debouncedChange]);
const handleCustomToggle = useCallback(() => {
if (isCustomEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [isCustomEnabled, debouncedChange]);
if (!isEnabled && !isPinned) {
return null;
}
return (
<div className="flex">
<CheckboxButton
className={cn('max-w-fit', isEnabled && 'rounded-r-none border-r-0')}
checked={isEnabled}
setValue={handleToggle}
label={localize('com_ui_artifacts')}
isCheckedClassName="border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10"
icon={<WandSparkles className="icon-md" />}
/>
{isEnabled && (
<Ariakit.MenuProvider open={isPopoverOpen} setOpen={setIsPopoverOpen}>
<Ariakit.MenuButton
className={cn(
'w-7 rounded-l-none rounded-r-full border-b border-l-0 border-r border-t border-border-light md:w-6',
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
'transition-colors',
)}
onClick={(e) => e.stopPropagation()}
>
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
</Ariakit.MenuButton>
<Ariakit.Menu
gutter={8}
className={cn(
'animate-popover z-50 flex max-h-[300px]',
'flex-col overflow-auto overscroll-contain rounded-xl',
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
'border border-border-light',
'min-w-[250px] outline-none',
)}
portal
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
)}
</div>
);
}
export default memo(Artifacts);

View File

@@ -0,0 +1,147 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight, WandSparkles } from 'lucide-react';
import { ArtifactModes } from 'librechat-data-provider';
import { PinIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ArtifactsSubMenuProps {
isArtifactsPinned: boolean;
setIsArtifactsPinned: (value: boolean) => void;
artifactsMode: string;
handleArtifactsToggle: () => void;
handleShadcnToggle: () => void;
handleCustomToggle: () => void;
}
const ArtifactsSubMenu = ({
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
}: ArtifactsSubMenuProps) => {
const localize = useLocalize();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<WandSparkles className="icon-md" />
<span>{localize('com_ui_artifacts')}</span>
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsArtifactsPinned(!isArtifactsPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isArtifactsPinned} />
</div>
</button>
</Ariakit.MenuItem>
{isEnabled && (
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
)}
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
);
};
export default React.memo(ArtifactsSubMenu);

View File

@@ -18,6 +18,7 @@ import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import ToolDialogs from './ToolDialogs';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import MCPSelect from './MCPSelect';
import WebSearch from './WebSearch';
import store from '~/store';
@@ -27,6 +28,7 @@ interface BadgeRowProps {
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
conversationId?: string | null;
isSubmitting?: boolean;
isInChat: boolean;
}
@@ -140,6 +142,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
function BadgeRow({
showEphemeralBadges,
conversationId,
isSubmitting,
onChange,
onToggle,
isInChat,
@@ -317,7 +320,7 @@ function BadgeRow({
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<BadgeRowProvider conversationId={conversationId}>
<BadgeRowProvider conversationId={conversationId} isSubmitting={isSubmitting}>
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{showEphemeralBadges === true && <ToolsDropdown />}
{tempBadges.map((badge, index) => (
@@ -364,6 +367,7 @@ function BadgeRow({
<WebSearch />
<CodeInterpreter />
<FileSearch />
<Artifacts />
<MCPSelect />
</>
)}

View File

@@ -305,6 +305,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
</div>
<BadgeRow
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
isSubmitting={isSubmitting || isSubmittingAdded}
conversationId={conversationId}
onChange={setBadges}
isInChat={

View File

@@ -4,18 +4,21 @@ import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
import AttachFile from './AttachFile';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
@@ -25,7 +28,9 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
} else if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return (
<AttachFileMenu
disabled={disableInputs}
@@ -34,7 +39,6 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
/>
);
}
return null;
}

View File

@@ -2,11 +2,10 @@ import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize, useFileHandling } from '~/hooks';
import { ephemeralAgentByConvoId } from '~/store';
import { cn } from '~/utils';
@@ -23,20 +22,17 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
const [isPopoverActive, setIsPopoverActive] = useState(false);
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
const { agentsConfig } = useGetAgentsConfig();
/** TODO: Ephemeral Agent Capabilities
* Allow defining agent capabilities on a per-endpoint basis
* Use definition for agents endpoint for ephemeral agents
* */
const capabilities = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
[endpointsConfig],
);
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const handleUploadClick = (isImage?: boolean) => {
if (!inputRef.current) {
@@ -60,7 +56,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
},
];
if (capabilities.includes(EToolResources.ocr)) {
if (capabilities.ocrEnabled) {
items.push({
label: localize('com_ui_upload_ocr_text'),
onClick: () => {
@@ -71,7 +67,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
});
}
if (capabilities.includes(EToolResources.file_search)) {
if (capabilities.fileSearchEnabled) {
items.push({
label: localize('com_ui_upload_file_search'),
onClick: () => {
@@ -83,7 +79,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
});
}
if (capabilities.includes(EToolResources.execute_code)) {
if (capabilities.codeEnabled) {
items.push({
label: localize('com_ui_upload_code_files'),
onClick: () => {

View File

@@ -2,11 +2,18 @@ import React, { useState, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
import type { MenuItemProps } from '~/common';
import { Permissions, PermissionTypes, AuthType } from 'librechat-data-provider';
import {
AuthType,
Permissions,
ArtifactModes,
PermissionTypes,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import { TooltipAnchor, DropdownPopup } from '~/components';
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
import { PinIcon, VectorIcon } from '~/components/svg';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { cn } from '~/utils';
@@ -21,12 +28,17 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const {
webSearch,
mcpSelect,
artifacts,
fileSearch,
agentsConfig,
startupConfig,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = useBadgeRowContext();
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm;
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
@@ -42,6 +54,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
authData: codeAuthData,
} = codeInterpreter;
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
const {
mcpValues,
mcpServerNames,
@@ -72,19 +85,46 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ isChecked: newValue });
webSearch.debouncedChange({ value: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ isChecked: newValue });
codeInterpreter.debouncedChange({ value: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ isChecked: newValue });
fileSearch.debouncedChange({ value: newValue });
}, [fileSearch]);
const handleArtifactsToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (!currentState || currentState === '') {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: '' });
}
}, [artifacts]);
const handleShadcnToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (currentState === ArtifactModes.SHADCNUI) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [artifacts]);
const handleCustomToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (currentState === ArtifactModes.CUSTOM) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [artifacts]);
const handleMCPToggle = useCallback(
(serverName: string) => {
const currentValues = mcpSelect.mcpValues ?? [];
@@ -98,9 +138,10 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
const dropdownItems = useMemo(() => {
const items: MenuItemProps[] = [];
items.push({
const dropdownItems: MenuItemProps[] = [];
if (fileSearchEnabled) {
dropdownItems.push({
onClick: handleFileSearchToggle,
hideOnClick: false,
render: (props) => (
@@ -129,159 +170,149 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
</div>
),
});
}
if (canUseWebSearch) {
items.push({
onClick: handleWebSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<Globe className="icon-md" />
<span>{localize('com_ui_web_search')}</span>
</div>
<div className="flex items-center gap-1">
{showWebSearchSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchDialogOpen(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure web search"
ref={searchMenuTriggerRef}
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
if (canUseWebSearch && webSearchEnabled) {
dropdownItems.push({
onClick: handleWebSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<Globe className="icon-md" />
<span>{localize('com_ui_web_search')}</span>
</div>
<div className="flex items-center gap-1">
{showWebSearchSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
setIsSearchDialogOpen(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
'text-text-secondary hover:text-text-primary',
)}
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
aria-label="Configure web search"
ref={searchMenuTriggerRef}
>
<div className="h-4 w-4">
<PinIcon unpin={isSearchPinned} />
<Settings className="h-4 w-4" />
</div>
</button>
</div>
</div>
),
});
}
if (canRunCode) {
items.push({
onClick: handleCodeInterpreterToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<TerminalSquareIcon className="icon-md" />
<span>{localize('com_assistants_code_interpreter')}</span>
</div>
<div className="flex items-center gap-1">
{showCodeSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodeDialogOpen(true);
}}
ref={codeMenuTriggerRef}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure code interpreter"
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isSearchPinned} />
</div>
</button>
</div>
</div>
),
});
}
if (canRunCode && codeEnabled) {
dropdownItems.push({
onClick: handleCodeInterpreterToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<TerminalSquareIcon className="icon-md" />
<span>{localize('com_assistants_code_interpreter')}</span>
</div>
<div className="flex items-center gap-1">
{showCodeSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
setIsCodeDialogOpen(true);
}}
ref={codeMenuTriggerRef}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isCodePinned && 'text-text-primary hover:text-text-primary',
'text-text-secondary hover:text-text-primary',
)}
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
aria-label="Configure code interpreter"
>
<div className="h-4 w-4">
<PinIcon unpin={isCodePinned} />
<Settings className="h-4 w-4" />
</div>
</button>
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isCodePinned && 'text-text-primary hover:text-text-primary',
)}
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isCodePinned} />
</div>
</button>
</div>
),
});
}
</div>
),
});
}
if (mcpServerNames && mcpServerNames.length > 0) {
items.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu
{...props}
mcpValues={mcpValues}
isMCPPinned={isMCPPinned}
placeholder={mcpPlaceholder}
mcpServerNames={mcpServerNames}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
),
});
}
if (artifactsEnabled) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<ArtifactsSubMenu
{...props}
isArtifactsPinned={isArtifactsPinned}
setIsArtifactsPinned={setIsArtifactsPinned}
artifactsMode={artifacts.toggleState as string}
handleArtifactsToggle={handleArtifactsToggle}
handleShadcnToggle={handleShadcnToggle}
handleCustomToggle={handleCustomToggle}
/>
),
});
}
return items;
}, [
localize,
mcpValues,
canRunCode,
isMCPPinned,
isCodePinned,
mcpPlaceholder,
mcpServerNames,
isSearchPinned,
setIsMCPPinned,
canUseWebSearch,
setIsCodePinned,
handleMCPToggle,
showCodeSettings,
setIsSearchPinned,
isFileSearchPinned,
codeMenuTriggerRef,
setIsCodeDialogOpen,
searchMenuTriggerRef,
showWebSearchSettings,
setIsFileSearchPinned,
handleWebSearchToggle,
setIsSearchDialogOpen,
handleFileSearchToggle,
handleCodeInterpreterToggle,
]);
if (mcpServerNames && mcpServerNames.length > 0) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu
{...props}
mcpValues={mcpValues}
isMCPPinned={isMCPPinned}
placeholder={mcpPlaceholder}
mcpServerNames={mcpServerNames}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
),
});
}
const menuTrigger = (
<TooltipAnchor

View File

@@ -8,7 +8,7 @@ import { useBadgeRowContext } from '~/Providers';
function WebSearch() {
const localize = useLocalize();
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData;
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({
@@ -21,7 +21,7 @@ function WebSearch() {
}
return (
(webSearch || isPinned) && (
(isPinned || (webSearch && authData?.authenticated)) && (
<CheckboxButton
ref={badgeTriggerRef}
className="max-w-fit"

View File

@@ -81,14 +81,23 @@ const ContentParts = memo(
return (
<>
{content.map((part, idx) => {
if (part?.type !== ContentTypes.TEXT || typeof part.text !== 'string') {
if (!part) {
return null;
}
const isTextPart =
part?.type === ContentTypes.TEXT ||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
const isThinkPart =
part?.type === ContentTypes.THINK ||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
if (!isTextPart && !isThinkPart) {
return null;
}
return (
<EditTextPart
index={idx}
text={part.text}
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
messageId={messageId}
isSubmitting={isSubmitting}
enterEdit={enterEdit}

View File

@@ -1,8 +1,9 @@
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { ContentTypes } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common';
import Container from '~/components/Chat/Messages/Content/Container';
import { useChatContext, useAddedChatContext } from '~/Providers';
@@ -12,18 +13,19 @@ import { useLocalize } from '~/hooks';
import store from '~/store';
const EditTextPart = ({
text,
part,
index,
messageId,
isSubmitting,
enterEdit,
}: Omit<TEditProps, 'message' | 'ask'> & {
}: Omit<TEditProps, 'message' | 'ask' | 'text'> & {
index: number;
messageId: string;
part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate;
}) => {
const localize = useLocalize();
const { addedIndex } = useAddedChatContext();
const { getMessages, setMessages, conversation } = useChatContext();
const { ask, getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
@@ -34,15 +36,16 @@ const EditTextPart = ({
[getMessages, messageId],
);
const chatDirection = useRecoilValue(store.chatDirection);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? '');
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const isRTL = chatDirection?.toLowerCase() === 'rtl';
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
text: text ?? '',
text: (ContentTypes.THINK in part ? part.think : part.text) || '',
},
});
@@ -55,15 +58,7 @@ const EditTextPart = ({
}
}, []);
/*
const resubmitMessage = () => {
showToast({
status: 'warning',
message: localize('com_warning_resubmit_unsupported'),
});
// const resubmitMessage = (data: { text: string }) => {
// Not supported by AWS Bedrock
const resubmitMessage = (data: { text: string }) => {
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId);
@@ -73,17 +68,19 @@ const EditTextPart = ({
ask(
{ ...parentMessage },
{
editedText: data.text,
editedContent: {
index,
text: data.text,
type: part.type,
},
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
},
);
setSiblingIdx((siblingIdx ?? 0) - 1);
enterEdit(true);
};
*/
const updateMessage = (data: { text: string }) => {
const messages = getMessages();
@@ -167,13 +164,13 @@ const EditTextPart = ({
/>
</div>
<div className="mt-2 flex w-full justify-center text-center">
{/* <button
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={handleSubmit(resubmitMessage)}
>
{localize('com_ui_save_submit')}
</button> */}
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}

View File

@@ -233,9 +233,17 @@ export default function Fork({
status: 'info',
});
},
onError: () => {
onError: (error) => {
/** Rate limit error (429 status code) */
const isRateLimitError =
(error as any)?.response?.status === 429 ||
(error as any)?.status === 429 ||
(error as any)?.statusCode === 429;
showToast({
message: localize('com_ui_fork_error'),
message: isRateLimitError
? localize('com_ui_fork_error_rate_limit')
: localize('com_ui_fork_error'),
status: 'error',
});
},

View File

@@ -62,6 +62,7 @@ const errorMessages = {
const { info } = json;
return info;
},
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
[ViolationTypes.BAN]:
'Your account has been temporarily banned due to violations of our service.',
invalid_api_key:

View File

@@ -17,7 +17,6 @@ import {
General,
Chat,
Speech,
Beta,
Commands,
Data,
Account,
@@ -233,9 +232,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Content value={SettingsTabValues.CHAT}>
<Chat />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.BETA}>
<Beta />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}>
<Commands />
</Tabs.Content>

View File

@@ -1,18 +0,0 @@
import { memo } from 'react';
import CodeArtifacts from './CodeArtifacts';
import ChatBadges from './ChatBadges';
function Beta() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<CodeArtifacts />
</div>
{/* <div className="pb-3">
<ChatBadges />
</div> */}
</div>
);
}
export default memo(Beta);

View File

@@ -1,22 +0,0 @@
import { useSetRecoilState } from 'recoil';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function ChatBadges() {
const setIsEditing = useSetRecoilState<boolean>(store.isEditingBadges);
const localize = useLocalize();
const handleEditChatBadges = () => {
setIsEditing(true);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_edit_chat_badges')}</div>
<Button variant="outline" onClick={handleEditChatBadges}>
{localize('com_ui_edit')}
</Button>
</div>
);
}

View File

@@ -1,95 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function CodeArtifacts() {
const [codeArtifacts, setCodeArtifacts] = useRecoilState<boolean>(store.codeArtifacts);
const [includeShadcnui, setIncludeShadcnui] = useRecoilState<boolean>(store.includeShadcnui);
const [customPromptMode, setCustomPromptMode] = useRecoilState<boolean>(store.customPromptMode);
const localize = useLocalize();
const handleCodeArtifactsChange = (value: boolean) => {
setCodeArtifacts(value);
if (!value) {
setIncludeShadcnui(false);
setCustomPromptMode(false);
}
};
const handleIncludeShadcnuiChange = (value: boolean) => {
setIncludeShadcnui(value);
};
const handleCustomPromptModeChange = (value: boolean) => {
setCustomPromptMode(value);
if (value) {
setIncludeShadcnui(false);
}
};
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{localize('com_ui_artifacts')}</h3>
<div className="space-y-2">
<SwitchItem
id="codeArtifacts"
label={localize('com_ui_artifacts_toggle')}
checked={codeArtifacts}
onCheckedChange={handleCodeArtifactsChange}
hoverCardText="com_nav_info_code_artifacts"
/>
<SwitchItem
id="includeShadcnui"
label={localize('com_ui_include_shadcnui')}
checked={includeShadcnui}
onCheckedChange={handleIncludeShadcnuiChange}
hoverCardText="com_nav_info_include_shadcnui"
disabled={!codeArtifacts || customPromptMode}
/>
<SwitchItem
id="customPromptMode"
label={localize('com_ui_custom_prompt_mode')}
checked={customPromptMode}
onCheckedChange={handleCustomPromptModeChange}
hoverCardText="com_nav_info_custom_prompt_mode"
disabled={!codeArtifacts}
/>
</div>
</div>
);
}
function SwitchItem({
id,
label,
checked,
onCheckedChange,
hoverCardText,
disabled = false,
}: {
id: string;
label: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
hoverCardText: string;
disabled?: boolean;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={disabled ? 'text-gray-400' : ''}>{label}</div>
<HoverCardSettings side="bottom" text={hoverCardText} />
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className="ml-4"
data-testid={id}
disabled={disabled}
/>
</div>
);
}

View File

@@ -86,6 +86,7 @@ export const LangSelector = ({
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
{ value: 'he-HE', label: localize('com_nav_lang_hebrew') },
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
{ value: 'hy-AM', label: localize('com_nav_lang_armenian') },
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
@@ -96,9 +97,11 @@ export const LangSelector = ({
{ 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: 'lv-LV', label: localize('com_nav_lang_latvian') },
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },
{ value: 'th-TH', label: localize('com_nav_lang_thai') },
{ value: 'tr-TR', label: localize('com_nav_lang_turkish') },
{ value: 'ug', label: localize('com_nav_lang_uyghur') },
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },

View File

@@ -1,7 +1,6 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import { Button } from '~/components/ui';
const CreatePromptButton: React.FC<{ isChatRoute: boolean }> = ({ isChatRoute }) => {
const navigate = useNavigate();
const localize = useLocalize();
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
});
return (
<>
{hasCreateAccess && (
<div className="flex w-full justify-end">
<Button
variant="outline"
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
onClick={() => navigate('/d/prompts/new')}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_create_prompt')}
</Button>
</div>
)}
</>
);
};
export default CreatePromptButton;

View File

@@ -1,5 +1,14 @@
import { Cog } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import {
Label,
Switch,
Button,
OGDialog,
OGDialogTrigger,
OGDialogContent,
OGDialogTitle,
} from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@@ -22,20 +31,30 @@ export default function AutoSendPrompt({
};
return (
<div
className={cn(
'flex select-none items-center justify-end gap-2 text-right text-sm',
className,
)}
>
<div> {localize('com_nav_auto_send_prompts')} </div>
<Switch
aria-label="toggle-auto-send-prompts"
id="autoSendPrompts"
checked={autoSendPrompts}
onCheckedChange={handleCheckedChange}
data-testid="autoSendPrompts"
/>
</div>
<>
<OGDialog>
<OGDialogTrigger className="flex items-center justify-center">
<Button size="sm" variant="outline" className="bg-transparent hover:bg-surface-hover">
<Cog className="h-4 w-4 text-text-primary" />
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-96">
<OGDialogTitle className="text-lg font-semibold">
{localize('com_ui_prompts_settings')}
</OGDialogTitle>
<div className={cn('flex justify-between text-text-secondary', className)}>
<Label>{localize('com_nav_auto_send_prompts')}</Label>
<Switch
aria-label="toggle-auto-send-prompts"
id="autoSendPrompts"
checked={autoSendPrompts}
onCheckedChange={handleCheckedChange}
data-testid="autoSendPrompts"
/>
</div>
</OGDialogContent>
</OGDialog>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
} from '~/components/ui';
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import FavoriteButton from '~/components/Prompts/Groups/FavoriteButton';
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard';
import { detectVariables } from '~/utils';
@@ -64,6 +65,7 @@ function ChatGroupItem({
{groupIsGlobal === true && (
<EarthIcon className="icon-md text-green-400" aria-label="Global prompt group" />
)}
<FavoriteButton groupId={group._id ?? ''} size="16" />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button

View File

@@ -2,12 +2,12 @@ import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'rea
import { EarthIcon, Pen } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
import { Input, Label, Button, OGDialog, OGDialogTrigger, TrashIcon } from '~/components';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import { Input, Label, Button, OGDialog, OGDialogTrigger } from '~/components/ui';
import FavoriteButton from '~/components/Prompts/Groups/FavoriteButton';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useAuthContext } from '~/hooks';
import { TrashIcon } from '~/components/svg';
import { cn } from '~/utils/';
interface DashGroupItemProps {
@@ -49,7 +49,6 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
const { isLoading } = updateGroup;
const handleSaveRename = useCallback(() => {
console.log(group._id ?? '', { name: nameInputValue });
updateGroup.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
}, [group._id, nameInputValue, updateGroup]);
@@ -99,6 +98,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
aria-label={localize('com_ui_global_group')}
/>
)}
<FavoriteButton groupId={group._id ?? ''} size="16" />
{(isOwner || user?.role === SystemRoles.ADMIN) && (
<>
<OGDialog>

View File

@@ -0,0 +1,74 @@
import React, { memo, useCallback } from 'react';
import { useTogglePromptFavorite, useGetUserPromptPreferences } from '~/data-provider';
import { Button, StarIcon } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface FavoriteButtonProps {
groupId: string;
size?: string | number;
onToggle?: (isFavorite: boolean) => void;
}
function FavoriteButton({ groupId, size = '1em', onToggle }: FavoriteButtonProps) {
const localize = useLocalize();
const { data: preferences } = useGetUserPromptPreferences();
const toggleFavorite = useTogglePromptFavorite();
const isFavorite = preferences?.favorites?.includes(groupId) ?? false;
const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite.mutate(
{ groupId },
{
onSuccess: () => {
onToggle?.(!isFavorite);
},
},
);
},
[groupId, isFavorite, onToggle, toggleFavorite],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleToggle(e as unknown as React.MouseEvent);
}
},
[handleToggle],
);
return (
<Button
variant="ghost"
onClick={handleToggle}
onKeyDown={handleKeyDown}
disabled={toggleFavorite.isLoading}
aria-label={
isFavorite ? localize('com_ui_remove_from_favorites') : localize('com_ui_add_to_favorites')
}
title={
isFavorite ? localize('com_ui_remove_from_favorites') : localize('com_ui_add_to_favorites')
}
className="h-8 w-8 p-0 hover:bg-surface-hover"
>
<StarIcon
size={size}
filled={isFavorite}
className={cn(
'transition-colors duration-200',
isFavorite
? 'text-yellow-500 hover:text-yellow-600'
: 'text-gray-400 hover:text-yellow-500',
)}
/>
</Button>
);
}
export default memo(FavoriteButton);

View File

@@ -1,5 +1,5 @@
import { ListFilter, User, Share2 } from 'lucide-react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SystemCategories } from 'librechat-data-provider';
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
@@ -19,7 +19,6 @@ export default function FilterPrompts({
const setCategory = useSetRecoilState(store.promptsCategory);
const categoryFilter = useRecoilValue(store.promptsCategory);
const { categories } = useCategories('h-4 w-4');
const [isSearching, setIsSearching] = useState(false);
const filterOptions = useMemo(() => {
const baseOptions: Option[] = [
@@ -41,7 +40,7 @@ export default function FilterPrompts({
{ divider: true, value: null },
];
const categoryOptions = categories
const categoryOptions = categories?.length
? [...categories]
: [
{
@@ -55,22 +54,18 @@ export default function FilterPrompts({
const onSelect = useCallback(
(value: string) => {
if (value === SystemCategories.ALL) {
setCategory('');
} else {
setCategory(value);
}
setCategory(value === SystemCategories.ALL ? '' : value);
},
[setCategory],
);
useEffect(() => {
setIsSearching(true);
const timeout = setTimeout(() => {
setIsSearching(false);
}, 500);
return () => clearTimeout(timeout);
}, [displayName]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
setName(e.target.value);
},
[setName],
);
return (
<div className={cn('flex w-full gap-2 text-text-primary', className)}>
@@ -78,7 +73,7 @@ export default function FilterPrompts({
value={categoryFilter || SystemCategories.ALL}
onChange={onSelect}
options={filterOptions}
className="bg-transparent"
className="rounded-lg bg-transparent"
icon={<ListFilter className="h-4 w-4" />}
label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')}
@@ -86,11 +81,7 @@ export default function FilterPrompts({
/>
<AnimatedSearchInput
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setName(e.target.value);
}}
isSearching={isSearching}
onChange={handleSearchChange}
placeholder={localize('com_ui_filter_prompts_name')}
/>
</div>

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
@@ -18,14 +16,14 @@ export default function GroupSidePanel({
groupsQuery,
promptGroups,
hasPreviousPage,
isChatRoute,
}: {
children?: React.ReactNode;
isDetailView?: boolean;
className?: string;
isChatRoute: boolean;
} & ReturnType<typeof usePromptGroupsNav>) {
const location = useLocation();
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
return (
<div

View File

@@ -1,81 +1,107 @@
import { Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
import {
RankablePromptList,
SortedPromptList,
RankingProvider,
} from '~/components/Prompts/Groups/RankingComponent';
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import { Button, Skeleton } from '~/components/ui';
import { Skeleton } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { useMemo } from 'react';
interface ListProps {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
enableRanking?: boolean;
}
export default function List({
groups = [],
isChatRoute,
isLoading,
}: {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
}) {
const navigate = useNavigate();
enableRanking = true,
}: ListProps) {
const localize = useLocalize();
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
});
const renderGroupItem = useMemo(
() => (group: TPromptGroup) => {
const Component = isChatRoute ? ChatGroupItem : DashGroupItem;
return <Component key={group._id} group={group} instanceProjectId={instanceProjectId} />;
},
[isChatRoute, instanceProjectId],
);
const emptyMessage = localize('com_ui_nothing_found');
if (isLoading) {
return (
<RankingProvider>
<div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">
{isChatRoute ? (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
) : (
Array.from({ length: 10 }).map((_, index) => (
<Skeleton
key={index}
className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4"
/>
))
)}
</div>
</div>
</div>
</RankingProvider>
);
}
if (groups.length === 0) {
return (
<RankingProvider>
<div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">
{isChatRoute ? (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
{emptyMessage}
</div>
) : (
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
{emptyMessage}
</div>
)}
</div>
</div>
</div>
</RankingProvider>
);
}
const shouldUseRanking = !isChatRoute && enableRanking;
const renderContent = () => {
if (isChatRoute) {
return <SortedPromptList groups={groups} renderItem={renderGroupItem} />;
}
if (shouldUseRanking) {
return <RankablePromptList groups={groups} renderItem={renderGroupItem} />;
}
return groups.map((group) => renderGroupItem(group));
};
return (
<div className="flex h-full flex-col">
{hasCreateAccess && (
<div className="flex w-full justify-end">
<Button
variant="outline"
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
onClick={() => navigate('/d/prompts/new')}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_create_prompt')}
</Button>
</div>
)}
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading &&
!isChatRoute &&
Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4" />
))}
{!isLoading && groups.length === 0 && isChatRoute && (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{!isLoading && groups.length === 0 && !isChatRoute && (
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{groups.map((group) => {
if (isChatRoute) {
return (
<ChatGroupItem
key={group._id}
group={group}
instanceProjectId={instanceProjectId}
/>
);
}
return (
<DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />
);
})}
<RankingProvider>
<div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">{renderContent()}</div>
</div>
</div>
</div>
</RankingProvider>
);
}

View File

@@ -1,6 +1,8 @@
import { memo } from 'react';
import { Button, ThemeSelector } from '~/components/ui';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import { Button } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
function PanelNavigation({
prevPage,
@@ -19,14 +21,16 @@ function PanelNavigation({
}) {
const localize = useLocalize();
return (
<>
<div className="flex gap-2">{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}</div>
<div
className="flex items-center justify-between gap-2"
role="navigation"
aria-label="Pagination"
>
<Button variant="outline" size="sm" onClick={() => prevPage()} disabled={!hasPreviousPage}>
<div className={cn('my-1 flex justify-between', !isChatRoute && 'mx-2')}>
<AutoSendPrompt className="text-xs dark:text-white" />
<div className="mb-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => prevPage()}
disabled={!hasPreviousPage}
className="bg-transparent hover:bg-surface-hover"
>
{localize('com_ui_prev')}
</Button>
<Button
@@ -34,6 +38,7 @@ function PanelNavigation({
size="sm"
onClick={() => nextPage()}
disabled={!hasNextPage || isFetching}
className="bg-transparent hover:bg-surface-hover"
>
{localize('com_ui_next')}
</Button>

View File

@@ -0,0 +1,294 @@
import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react';
import { GripVertical } from 'lucide-react';
import { useDrag, useDrop, useDragLayer } from 'react-dnd';
import type { TPromptGroup } from 'librechat-data-provider';
import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider';
import CategoryIcon from './CategoryIcon';
import { Label } from '~/components';
import { cn } from '~/utils';
const ITEM_TYPE = 'PROMPT_GROUP';
interface DraggablePromptItemProps {
group: TPromptGroup;
index: number;
moveItem: (dragIndex: number, hoverIndex: number) => void;
isDragging: boolean;
children: ReactNode;
}
interface DragItem {
index: number;
id: string;
type: string;
group: TPromptGroup;
}
const sortGroups = (groups: TPromptGroup[], rankings: any[], favorites: string[]) => {
const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order]));
return [...groups].sort((a, b) => {
const aId = a._id ?? '';
const bId = b._id ?? '';
const aIsFavorite = favorites.includes(aId);
const bIsFavorite = favorites.includes(bId);
if (aIsFavorite && !bIsFavorite) return -1;
if (!aIsFavorite && bIsFavorite) return 1;
const aRank = rankingMap.get(aId);
const bRank = rankingMap.get(bId);
if (aRank !== undefined && bRank !== undefined) return aRank - bRank;
if (aRank !== undefined) return -1;
if (bRank !== undefined) return 1;
return a.name.localeCompare(b.name);
});
};
function DraggablePromptItem({
group,
index,
moveItem,
isDragging: isAnyDragging,
children,
}: DraggablePromptItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag, preview] = useDrag({
type: ITEM_TYPE,
item: { type: ITEM_TYPE, index, id: group._id, group },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
});
const [{ isOver }, drop] = useDrop<DragItem, void, { isOver: boolean }>({
accept: ITEM_TYPE,
hover: (item, monitor) => {
if (!ref.current || item.index === index) return;
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleY = hoverBoundingRect.height / 2;
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (item.index < index && hoverClientY < hoverMiddleY * 0.8) return;
if (item.index > index && hoverClientY > hoverMiddleY * 1.2) return;
moveItem(item.index, index);
item.index = index;
},
collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }) }),
});
drag(drop(ref));
useEffect(() => {
preview(new Image(), { captureDraggingState: false });
}, [preview]);
return (
<div
ref={ref}
className={cn(
'group relative transition-all duration-300 ease-in-out',
isDragging && 'opacity-0',
isAnyDragging && !isDragging && 'transition-transform',
isOver && !isDragging && 'scale-[1.02]',
)}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<div
className={cn(
'absolute left-2 top-1/2 z-10 -translate-y-1/2 opacity-0 group-hover:opacity-100',
isDragging && 'opacity-100',
)}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</div>
<div className="pl-8">{children}</div>
</div>
);
}
function CustomDragLayer() {
const { itemType, item, currentOffset, isDragging } = useDragLayer((monitor) => ({
itemType: monitor.getItemType(),
item: monitor.getItem() as DragItem,
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}));
if (!isDragging || !currentOffset || itemType !== ITEM_TYPE || !item?.group) return null;
return (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 100,
left: 0,
top: 0,
width: '100%',
height: '100%',
}}
>
<div
style={{
transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
}}
>
<div className="mx-2 my-2 flex h-[60px] w-[430px] min-w-[300px] cursor-pointer rounded-lg border border-border-light bg-surface-primary p-3 opacity-90 shadow-lg">
<div className="flex items-center gap-2 truncate pr-2">
<CategoryIcon
category={item.group.category ?? ''}
className="icon-lg"
aria-hidden="true"
/>
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
{item.group.name}
</Label>
</div>
</div>
</div>
</div>
);
}
function SortedPromptList({
groups,
renderItem,
}: {
groups: TPromptGroup[];
renderItem: (group: TPromptGroup) => ReactNode;
}) {
const { data: preferences } = useGetUserPromptPreferences();
const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]);
useEffect(() => {
if (!groups?.length) {
setSortedGroups([]);
return;
}
const rankings = preferences?.rankings || [];
const favorites = preferences?.favorites || [];
setSortedGroups(sortGroups(groups, rankings, favorites));
}, [groups, preferences]);
return (
<div className="space-y-2">
{sortedGroups.map((group) => (
<div key={group._id} className="transition-all duration-300 ease-in-out">
{renderItem(group)}
</div>
))}
</div>
);
}
interface RankablePromptListProps {
groups: TPromptGroup[];
renderItem: (group: TPromptGroup) => ReactNode;
onRankingChange?: (rankings: string[]) => void;
}
function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) {
const { data: preferences } = useGetUserPromptPreferences();
const updateRankings = useUpdatePromptRankings();
const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]);
const [isDragging, setIsDragging] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!groups?.length) {
setSortedGroups([]);
return;
}
const rankings = preferences?.rankings || [];
const favorites = preferences?.favorites || [];
setSortedGroups(sortGroups(groups, rankings, favorites));
}, [groups, preferences]);
const moveItem = useCallback(
(dragIndex: number, hoverIndex: number) => {
if (dragIndex === hoverIndex) return;
setSortedGroups((prevGroups) => {
const newGroups = [...prevGroups];
const [draggedItem] = newGroups.splice(dragIndex, 1);
newGroups.splice(hoverIndex, 0, draggedItem);
return newGroups;
});
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
setSortedGroups((currentGroups) => {
const newRankings = currentGroups
.map((group, index) => (group._id ? { promptGroupId: group._id, order: index } : null))
.filter(
(ranking): ranking is { promptGroupId: string; order: number } => ranking !== null,
);
if (newRankings.length > 0) {
updateRankings
.mutateAsync({ rankings: newRankings })
.then(() => onRankingChange?.(newRankings.map((r) => r.promptGroupId)))
.catch(console.error);
}
return currentGroups;
});
}, 500);
},
[updateRankings, onRankingChange],
);
useEffect(() => {
const handleDragStart = () => setIsDragging(true);
const handleDragEnd = () => setIsDragging(false);
document.addEventListener('dragstart', handleDragStart);
document.addEventListener('dragend', handleDragEnd);
return () => {
document.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('dragend', handleDragEnd);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, []);
return (
<div className={cn('space-y-2 transition-all duration-300', isDragging && 'space-y-3')}>
{sortedGroups.map((group, index) => (
<div
key={group._id || index}
className="transition-all duration-300 ease-in-out"
style={{ transform: `translateY(${isDragging ? '2px' : '0'})` }}
>
<DraggablePromptItem
group={group}
index={index}
moveItem={moveItem}
isDragging={isDragging}
>
{renderItem(group)}
</DraggablePromptItem>
</div>
))}
</div>
);
}
function RankingProvider({ children }: { children: ReactNode }) {
return (
<div>
<CustomDragLayer />
{children}
</div>
);
}
export { RankablePromptList, SortedPromptList, RankingProvider };

View File

@@ -1,16 +1,23 @@
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import CreatePrompt from '~/components/Prompts/CreatePrompt';
import { usePromptGroupsNav } from '~/hooks';
export default function PromptsAccordion() {
const location = useLocation();
const groupsNav = usePromptGroupsNav();
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
return (
<div className="flex h-full w-full flex-col">
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
<div className="mt-2 flex h-full w-full flex-col">
<PromptSidePanel isChatRoute={isChatRoute} className="lg:w-full xl:w-full" {...groupsNav}>
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" />
<div className="flex w-full flex-row items-center justify-end">
<AutoSendPrompt className="text-xs dark:text-white" />
<div className="flex w-full flex-row items-center justify-between gap-2">
<ManagePrompts className="select-none" />
<CreatePrompt isChatRoute={isChatRoute} />
</div>
</PromptSidePanel>
</div>

View File

@@ -2,20 +2,20 @@ import { useMemo } from 'react';
import { ChevronLeft } from 'lucide-react';
import { AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { AgentForm, AgentPanelProps } from '~/common';
import type { AgentForm } from '~/common';
import { useAgentPanelContext } from '~/Providers';
import MaxAgentSteps from './MaxAgentSteps';
import AgentChain from './AgentChain';
import { useLocalize } from '~/hooks';
import AgentChain from './AgentChain';
import { Panel } from '~/common';
export default function AdvancedPanel({
agentsConfig,
setActivePanel,
}: Pick<AgentPanelProps, 'setActivePanel' | 'agentsConfig'>) {
export default function AdvancedPanel() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, watch } = methods;
const currentAgentId = watch('id');
const { agentsConfig, setActivePanel } = useAgentPanelContext();
const chainEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.chain) ?? false,
[agentsConfig],

View File

@@ -1,9 +1,10 @@
import React, { useState, useMemo, useCallback } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
@@ -26,17 +27,20 @@ const inputClass = cn(
removeFocusOutlines,
);
export default function AgentConfig({
agentsConfig,
createMutation,
endpointsConfig,
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'createMutation'>) {
const localize = useLocalize();
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
const {
actions,
setAction,
agentsConfig,
setActivePanel,
endpointsConfig,
groupedTools: allTools,
} = useAgentPanelContext();
const { control } = methods;
const provider = useWatch({ control, name: 'provider' });
@@ -45,34 +49,15 @@ export default function AgentConfig({
const tools = useWatch({ control, name: 'tools' });
const agent_id = useWatch({ control, name: 'id' });
const toolsEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.tools) ?? false,
[agentsConfig],
);
const actionsEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.actions) ?? false,
[agentsConfig],
);
const artifactsEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.artifacts) ?? false,
[agentsConfig],
);
const ocrEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.ocr) ?? false,
[agentsConfig],
);
const fileSearchEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) ?? false,
[agentsConfig],
);
const webSearchEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.web_search) ?? false,
[agentsConfig],
);
const codeEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) ?? false,
[agentsConfig],
);
const {
ocrEnabled,
codeEnabled,
toolsEnabled,
actionsEnabled,
artifactsEnabled,
webSearchEnabled,
fileSearchEnabled,
} = useAgentCapabilities(agentsConfig?.capabilities);
const context_files = useMemo(() => {
if (typeof agent === 'string') {

View File

@@ -7,8 +7,6 @@ import {
Constants,
SystemRoles,
EModelEndpoint,
TAgentsEndpoint,
TEndpointsConfig,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { AgentForm, StringOption } from '~/common';
@@ -30,19 +28,15 @@ import { Button } from '~/components';
import ModelPanel from './ModelPanel';
import { Panel } from '~/common';
export default function AgentPanel({
agentsConfig,
endpointsConfig,
}: {
agentsConfig: TAgentsEndpoint | null;
endpointsConfig: TEndpointsConfig;
}) {
export default function AgentPanel() {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const {
activePanel,
agentsConfig,
setActivePanel,
endpointsConfig,
setCurrentAgentId,
agent_id: current_agent_id,
} = useAgentPanelContext();
@@ -323,14 +317,10 @@ export default function AgentPanel({
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
<AgentConfig
createMutation={create}
agentsConfig={agentsConfig}
endpointsConfig={endpointsConfig}
/>
<AgentConfig createMutation={create} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (
<AdvancedPanel setActivePanel={setActivePanel} agentsConfig={agentsConfig} />
<AdvancedPanel />
)}
{canEditAgent && !agentQuery.isInitialLoading && (
<AgentFooter

View File

@@ -1,8 +1,5 @@
import { useEffect, useMemo } from 'react';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
import { useEffect } from 'react';
import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useGetEndpointsQuery } from '~/data-provider';
import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
@@ -22,21 +19,6 @@ function AgentPanelSwitchWithContext() {
const { conversation } = useChatContext();
const { activePanel, setCurrentAgentId } = useAgentPanelContext();
// TODO: Implement MCP endpoint
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
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 ?? '';
if (agent_id) {
@@ -57,5 +39,5 @@ function AgentPanelSwitchWithContext() {
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
return <AgentPanel />;
}

View File

@@ -226,7 +226,7 @@ export default function AgentTool({
}}
className={cn(
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
isExpanded ? 'opacity-100' : 'opacity-0',
isExpanded ? 'visible' : 'pointer-events-none invisible',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {

View File

@@ -60,7 +60,7 @@ export default function Artifacts() {
/>
<SwitchItem
id="includeShadcnui"
label={localize('com_ui_include_shadcnui_agent')}
label={localize('com_ui_include_shadcnui')}
checked={isShadcnEnabled}
onCheckedChange={handleShadcnuiChange}
hoverCardText={localize('com_nav_info_include_shadcnui')}

View File

@@ -1,13 +1,16 @@
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import {
AuthType,
SearchCategories,
RerankerTypes,
SearchProviders,
ScraperTypes,
} from 'librechat-data-provider';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common';
import { Input, Button, OGDialog, Label } from '~/components/ui';
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import InputSection, { type DropdownOption } from './InputSection';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import DropdownPopup from '~/components/ui/DropdownPopup';
import { Button, OGDialog } from '~/components/ui';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
@@ -36,151 +39,119 @@ export default function ApiKeyDialog({
}) {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
const [selectedReranker, setSelectedReranker] = useState<
RerankerTypes.JINA | RerankerTypes.COHERE
>(
config?.webSearch?.rerankerType === RerankerTypes.COHERE
? RerankerTypes.COHERE
: RerankerTypes.JINA,
const [selectedProvider, setSelectedProvider] = useState(
config?.webSearch?.searchProvider || SearchProviders.SERPER,
);
const [selectedReranker, setSelectedReranker] = useState(
config?.webSearch?.rerankerType || RerankerTypes.JINA,
);
const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL);
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
const providerItems: MenuItemProps[] = [
const providerOptions: DropdownOption[] = [
{
key: SearchProviders.SERPER,
label: localize('com_ui_web_search_provider_serper'),
onClick: () => {},
inputs: {
serperApiKey: {
placeholder: localize('com_ui_enter_api_key'),
type: 'password' as const,
link: {
url: 'https://serper.dev/api-keys',
text: localize('com_ui_web_search_provider_serper_key'),
},
},
},
},
{
key: SearchProviders.SEARXNG,
label: localize('com_ui_web_search_provider_searxng'),
inputs: {
searxngInstanceUrl: {
placeholder: localize('com_ui_web_search_searxng_instance_url'),
type: 'text' as const,
},
searxngApiKey: {
placeholder: localize('com_ui_web_search_searxng_api_key'),
type: 'password' as const,
},
},
},
];
const scraperItems: MenuItemProps[] = [
{
label: localize('com_ui_web_search_scraper_firecrawl'),
onClick: () => {},
},
];
const rerankerItems: MenuItemProps[] = [
const rerankerOptions: DropdownOption[] = [
{
key: RerankerTypes.JINA,
label: localize('com_ui_web_search_reranker_jina'),
onClick: () => setSelectedReranker(RerankerTypes.JINA),
inputs: {
jinaApiKey: {
placeholder: localize('com_ui_web_search_jina_key'),
type: 'password' as const,
link: {
url: 'https://jina.ai/api-dashboard/',
text: localize('com_ui_web_search_reranker_jina_key'),
},
},
},
},
{
key: RerankerTypes.COHERE,
label: localize('com_ui_web_search_reranker_cohere'),
onClick: () => setSelectedReranker(RerankerTypes.COHERE),
inputs: {
cohereApiKey: {
placeholder: localize('com_ui_web_search_cohere_key'),
type: 'password' as const,
link: {
url: 'https://dashboard.cohere.com/welcome/login',
text: localize('com_ui_web_search_reranker_cohere_key'),
},
},
},
},
];
const showProviderDropdown = !config?.webSearch?.searchProvider;
const showScraperDropdown = !config?.webSearch?.scraperType;
const showRerankerDropdown = !config?.webSearch?.rerankerType;
const scraperOptions: DropdownOption[] = [
{
key: ScraperTypes.FIRECRAWL,
label: localize('com_ui_web_search_scraper_firecrawl'),
inputs: {
firecrawlApiUrl: {
placeholder: localize('com_ui_web_search_firecrawl_url'),
type: 'text' as const,
},
firecrawlApiKey: {
placeholder: localize('com_ui_enter_api_key'),
type: 'password' as const,
link: {
url: 'https://docs.firecrawl.dev/introduction#api-key',
text: localize('com_ui_web_search_scraper_firecrawl_key'),
},
},
},
},
];
const [dropdownOpen, setDropdownOpen] = useState({
provider: false,
reranker: false,
scraper: false,
});
// Determine which categories are SYSTEM_DEFINED
const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1];
const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1];
const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1];
function renderRerankerInput() {
if (config?.webSearch?.rerankerType === RerankerTypes.JINA) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_jina_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('jinaApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://jina.ai/api-dashboard/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_jina_key')}
</a>
</div>
</>
);
}
if (config?.webSearch?.rerankerType === RerankerTypes.COHERE) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_cohere_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('cohereApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://dashboard.cohere.com/welcome/login"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_cohere_key')}
</a>
</div>
</>
);
}
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.JINA) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_jina_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('jinaApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://jina.ai/api-dashboard/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_jina_key')}
</a>
</div>
</>
);
}
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.COHERE) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_cohere_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('cohereApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://dashboard.cohere.com/welcome/login"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_cohere_key')}
</a>
</div>
</>
);
}
return null;
}
const handleProviderChange = (key: string) => {
setSelectedProvider(key as SearchProviders);
};
const handleRerankerChange = (key: string) => {
setSelectedReranker(key as RerankerTypes);
};
const handleScraperChange = (key: string) => {
setSelectedScraper(key as ScraperTypes);
};
return (
<OGDialog
@@ -195,153 +166,56 @@ export default function ApiKeyDialog({
main={
<>
<div className="mb-4 text-center font-medium">{localize('com_ui_web_search')}</div>
<div className="mb-4 text-center text-sm">
{localize('com_ui_web_search_api_subtitle')}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Search Provider Section */}
{/* Provider Section */}
{providerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_provider')}
</Label>
{showProviderDropdown ? (
<DropdownPopup
menuId="search-provider-dropdown"
items={providerItems}
isOpen={providerDropdownOpen}
setIsOpen={setProviderDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{localize('com_ui_web_search_provider_serper')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">
{localize('com_ui_web_search_provider_serper')}
</div>
)}
</div>
<Input
type="password"
placeholder={`${localize('com_ui_enter_api_key')}`}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('serperApiKey', { required: true })}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://serper.dev/api-key"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_provider_serper_key')}
</a>
</div>
</div>
<InputSection
title={localize('com_ui_web_search_provider')}
selectedKey={selectedProvider}
onSelectionChange={handleProviderChange}
dropdownOptions={providerOptions}
showDropdown={!config?.webSearch?.searchProvider}
register={register}
dropdownOpen={dropdownOpen.provider}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, provider: open }))
}
dropdownKey="provider"
/>
)}
{/* Scraper Section */}
{scraperAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_scraper')}
</Label>
{showScraperDropdown ? (
<DropdownPopup
menuId="scraper-dropdown"
items={scraperItems}
isOpen={scraperDropdownOpen}
setIsOpen={setScraperDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setScraperDropdownOpen(!scraperDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{localize('com_ui_web_search_scraper_firecrawl')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">
{localize('com_ui_web_search_scraper_firecrawl')}
</div>
)}
</div>
<Input
type="password"
placeholder={`${localize('com_ui_enter_api_key')}`}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
className="mb-2"
{...register('firecrawlApiKey')}
/>
<Input
type="text"
placeholder={localize('com_ui_web_search_firecrawl_url')}
className="mb-1"
{...register('firecrawlApiUrl')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://docs.firecrawl.dev/introduction#api-key"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_scraper_firecrawl_key')}
</a>
</div>
</div>
<InputSection
title={localize('com_ui_web_search_scraper')}
selectedKey={selectedScraper}
onSelectionChange={handleScraperChange}
dropdownOptions={scraperOptions}
showDropdown={!config?.webSearch?.scraperType}
register={register}
dropdownOpen={dropdownOpen.scraper}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, scraper: open }))
}
dropdownKey="scraper"
/>
)}
{/* Reranker Section */}
{rerankerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_reranker')}
</Label>
{showRerankerDropdown && (
<DropdownPopup
menuId="reranker-dropdown"
isOpen={rerankerDropdownOpen}
setIsOpen={setRerankerDropdownOpen}
items={rerankerItems}
trigger={
<Menu.MenuButton
onClick={() => setRerankerDropdownOpen(!rerankerDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{selectedReranker === RerankerTypes.JINA
? localize('com_ui_web_search_reranker_jina')
: localize('com_ui_web_search_reranker_cohere')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
)}
{!showRerankerDropdown && (
<div className="text-sm text-text-secondary">
{config?.webSearch?.rerankerType === RerankerTypes.COHERE
? localize('com_ui_web_search_reranker_cohere')
: localize('com_ui_web_search_reranker_jina')}
</div>
)}
</div>
{renderRerankerInput()}
</div>
<InputSection
title={localize('com_ui_web_search_reranker')}
selectedKey={selectedReranker}
onSelectionChange={handleRerankerChange}
dropdownOptions={rerankerOptions}
showDropdown={!config?.webSearch?.rerankerType}
register={register}
dropdownOpen={dropdownOpen.reranker}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, reranker: open }))
}
dropdownKey="reranker"
/>
)}
</form>
</>
@@ -353,10 +227,7 @@ export default function ApiKeyDialog({
}}
buttons={
isToolAuthenticated && (
<Button
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
<Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600">
{localize('com_ui_revoke')}
</Button>
)

View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import type { UseFormRegister } from 'react-hook-form';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common';
import { Input, Label } from '~/components/ui';
import DropdownPopup from '~/components/ui/DropdownPopup';
import { useLocalize } from '~/hooks';
interface InputConfig {
placeholder: string;
type?: 'text' | 'password';
link?: {
url: string;
text: string;
};
}
interface DropdownOption {
key: string;
label: string;
inputs?: Record<string, InputConfig>;
}
interface InputSectionProps {
title: string;
selectedKey: string;
onSelectionChange: (key: string) => void;
dropdownOptions: DropdownOption[];
showDropdown: boolean;
register: UseFormRegister<SearchApiKeyFormData>;
dropdownOpen: boolean;
setDropdownOpen: (open: boolean) => void;
dropdownKey: string;
}
export default function InputSection({
title,
selectedKey,
onSelectionChange,
dropdownOptions,
showDropdown,
register,
dropdownOpen,
setDropdownOpen,
dropdownKey,
}: InputSectionProps) {
const localize = useLocalize();
const [passwordVisibility, setPasswordVisibility] = useState<Record<string, boolean>>({});
const selectedOption = dropdownOptions.find((opt) => opt.key === selectedKey);
const dropdownItems: MenuItemProps[] = dropdownOptions.map((option) => ({
label: option.label,
onClick: () => onSelectionChange(option.key),
}));
const togglePasswordVisibility = (fieldName: string) => {
setPasswordVisibility((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}));
};
return (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">{title}</Label>
{showDropdown ? (
<DropdownPopup
menuId={`${dropdownKey}-dropdown`}
items={dropdownItems}
isOpen={dropdownOpen}
setIsOpen={setDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{selectedOption?.label}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">{selectedOption?.label}</div>
)}
</div>
{selectedOption?.inputs &&
Object.entries(selectedOption.inputs).map(([name, config], index) => (
<div key={name}>
<div className="relative">
<Input
type={'text'} // so password autofill doesn't show
placeholder={config.placeholder}
autoComplete={config.type === 'password' ? 'one-time-code' : 'off'}
readOnly={config.type === 'password'}
onFocus={
config.type === 'password' ? (e) => (e.target.readOnly = false) : undefined
}
className={`${index > 0 ? 'mb-2' : 'mb-2'} ${
config.type === 'password' ? 'pr-10' : ''
}`}
{...register(name as keyof SearchApiKeyFormData)}
/>
{config.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(name)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
aria-label={
passwordVisibility[name]
? localize('com_ui_hide_password')
: localize('com_ui_show_password')
}
>
<div className="relative h-4 w-4">
{passwordVisibility[name] ? (
<EyeOff className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
) : (
<Eye className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
)}
</div>
</button>
)}
</div>
{config.link && (
<div className="mt-1 text-xs text-text-secondary">
<a
href={config.link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{config.link.text}
</a>
</div>
)}
</div>
))}
</div>
);
}
export type { InputConfig, DropdownOption };

View File

@@ -52,6 +52,10 @@ export default function MemoryCreateDialog({
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
errorMessage = localize('com_ui_memory_key_exists');
}
// Check for key validation error (lowercase and underscores only)
else if (errorMessage.includes('lowercase letters and underscores')) {
errorMessage = localize('com_ui_memory_key_validation');
}
}
} else if (error.message) {
errorMessage = error.message;

View File

@@ -44,9 +44,29 @@ export default function MemoryEditDialog({
status: 'success',
});
},
onError: () => {
onError: (error: Error) => {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as any;
if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
// Check for duplicate key error
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
errorMessage = localize('com_ui_memory_key_exists');
}
// Check for key validation error (lowercase and underscores only)
else if (errorMessage.includes('lowercase letters and underscores')) {
errorMessage = localize('com_ui_memory_key_validation');
}
}
} else if (error.message) {
errorMessage = error.message;
}
showToast({
message: localize('com_ui_error'),
message: errorMessage,
status: 'error',
});
},

View File

@@ -18,6 +18,7 @@ function DynamicSlider({
setOption,
optionType,
options,
enumMappings,
readonly = false,
showDefault = false,
includeInput = true,
@@ -60,24 +61,68 @@ function DynamicSlider({
const enumToNumeric = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, mapping, index) => {
acc[mapping] = index;
return acc;
}, {} as Record<string, number>);
return options.reduce(
(acc, mapping, index) => {
acc[mapping] = index;
return acc;
},
{} as Record<string, number>,
);
}
return {};
}, [isEnum, options]);
const valueToEnumOption = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, option, index) => {
acc[index] = option;
return acc;
}, {} as Record<number, string>);
return options.reduce(
(acc, option, index) => {
acc[index] = option;
return acc;
},
{} as Record<number, string>,
);
}
return {};
}, [isEnum, options]);
const getDisplayValue = useCallback(
(value: string | number | undefined | null): string => {
if (isEnum && enumMappings && value != null) {
const stringValue = String(value);
// Check if the value exists in enumMappings
if (stringValue in enumMappings) {
const mappedValue = String(enumMappings[stringValue]);
// Check if the mapped value is a localization key
if (mappedValue.startsWith('com_')) {
return localize(mappedValue as TranslationKeys) ?? mappedValue;
}
return mappedValue;
}
}
// Always return a string for Input component compatibility
if (value != null) {
return String(value);
}
return String(defaultValue ?? '');
},
[isEnum, enumMappings, defaultValue, localize],
);
const getDefaultDisplayValue = useCallback((): string => {
if (defaultValue != null && enumMappings) {
const stringDefault = String(defaultValue);
if (stringDefault in enumMappings) {
const mappedValue = String(enumMappings[stringDefault]);
// Check if the mapped value is a localization key
if (mappedValue.startsWith('com_')) {
return localize(mappedValue as TranslationKeys) ?? mappedValue;
}
return mappedValue;
}
}
return String(defaultValue ?? '');
}, [defaultValue, enumMappings, localize]);
const handleValueChange = useCallback(
(value: number) => {
if (isEnum) {
@@ -115,12 +160,12 @@ function DynamicSlider({
<div className="flex w-full items-center justify-between">
<Label
htmlFor={`${settingKey}-dynamic-setting`}
className="text-left text-sm font-medium"
className="break-words text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue})
({localize('com_endpoint_default')}: {getDefaultDisplayValue()})
</small>
)}
</Label>
@@ -132,13 +177,13 @@ function DynamicSlider({
onChange={(value) => setInputValue(Number(value))}
max={range ? range.max : (options?.length ?? 0) - 1}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
step={range ? (range.step ?? 1) : 1}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 py-1 text-xs group-hover/temp:border-gray-200',
),
)}
/>
@@ -146,13 +191,13 @@ function DynamicSlider({
<Input
id={`${settingKey}-dynamic-setting-input`}
disabled={readonly}
value={selectedValue ?? defaultValue}
value={getDisplayValue(selectedValue)}
onChange={() => ({})}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
'reset-rc-number-input h-auto w-14 border-0 py-1 pl-1 text-center text-xs group-hover/temp:border-gray-200',
),
)}
/>
@@ -164,19 +209,23 @@ function DynamicSlider({
value={[
isEnum
? enumToNumeric[(selectedValue as number) ?? '']
: (inputValue as number) ?? (defaultValue as number),
: ((inputValue as number) ?? (defaultValue as number)),
]}
onValueChange={(value) => handleValueChange(value[0])}
onDoubleClick={() => setInputValue(defaultValue as string | number)}
max={max}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
step={range ? (range.step ?? 1) : 1}
className="flex h-4 w-full"
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left}
/>
)}

View File

@@ -50,7 +50,7 @@ function DynamicSwitch({
<div className="flex justify-between">
<Label
htmlFor={`${settingKey}-dynamic-switch`}
className="text-left text-sm font-medium"
className="break-words text-left text-sm font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (

View File

@@ -0,0 +1,37 @@
import React from 'react';
interface StarIconProps {
className?: string;
size?: string | number;
filled?: boolean;
}
export default function StarIcon({ className = '', size = '1em', filled = false }: StarIconProps) {
return filled ? (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26" />
</svg>
);
}

View File

@@ -60,6 +60,7 @@ export { default as CircleHelpIcon } from './CircleHelpIcon';
export { default as BedrockIcon } from './BedrockIcon';
export { default as ThumbUpIcon } from './ThumbUpIcon';
export { default as ThumbDownIcon } from './ThumbDownIcon';
export { default as StarIcon } from './StarIcon';
export { default as XAIcon } from './XAIcon';
export { default as PersonalizationIcon } from './PersonalizationIcon';
export { default as MCPIcon } from './MCPIcon';

View File

@@ -12,7 +12,10 @@ const CheckboxButton = React.forwardRef<
checked?: boolean;
defaultChecked?: boolean;
isCheckedClassName?: string;
setValue?: (values: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => void;
setValue?: (values: {
e?: React.ChangeEvent<HTMLInputElement>;
value: boolean | string;
}) => void;
}
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
const checkbox = useCheckboxStore();
@@ -22,7 +25,7 @@ const CheckboxButton = React.forwardRef<
if (typeof isChecked !== 'boolean') {
return;
}
setValue?.({ e, isChecked: !isChecked });
setValue?.({ e, value: !isChecked });
};
// Sync with controlled checked prop

View File

@@ -1,5 +1,5 @@
import { useRecoilValue } from 'recoil';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
@@ -327,3 +327,131 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
},
});
};
/* Prompt Favorites and Rankings */
export const useTogglePromptFavorite = (
options?: t.UpdatePromptGroupOptions,
): UseMutationResult<t.TPromptFavoriteResponse, unknown, { groupId: string }, unknown> => {
const { onMutate, onError, onSuccess } = options || {};
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: { groupId: string }) =>
dataService.togglePromptFavorite(variables.groupId),
onMutate: async (variables: { groupId: string }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: [QueryKeys.promptGroups] });
await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] });
// Snapshot the previous values
const previousPreferences = queryClient.getQueryData<t.TGetUserPromptPreferencesResponse>([
QueryKeys.userPromptPreferences,
]);
// Optimistically update the favorites
if (previousPreferences) {
const isFavorite = previousPreferences.favorites.includes(variables.groupId);
const newFavorites = isFavorite
? previousPreferences.favorites.filter((id) => id !== variables.groupId)
: [...previousPreferences.favorites, variables.groupId];
queryClient.setQueryData<t.TGetUserPromptPreferencesResponse>(
[QueryKeys.userPromptPreferences],
{
...previousPreferences,
favorites: newFavorites,
},
);
}
if (onMutate) {
return onMutate(variables);
}
return { previousPreferences };
},
onError: (err, variables, context) => {
// Revert optimistic update on error
if (context?.previousPreferences) {
queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences);
}
if (onError) {
onError(err, variables, context);
}
},
onSuccess: (response, variables, context) => {
// Invalidate and refetch related queries
queryClient.invalidateQueries({ queryKey: [QueryKeys.userPromptPreferences] });
queryClient.invalidateQueries({ queryKey: [QueryKeys.promptGroups] });
if (onSuccess) {
onSuccess(response, variables, context);
}
},
});
};
export const useUpdatePromptRankings = (
options?: t.UpdatePromptGroupOptions,
): UseMutationResult<t.TPromptRankingResponse, unknown, t.TPromptRankingRequest, unknown> => {
const { onMutate, onError, onSuccess } = options || {};
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: t.TPromptRankingRequest) => dataService.updatePromptRankings(variables),
onMutate: async (variables: t.TPromptRankingRequest) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] });
// Snapshot the previous values
const previousPreferences = queryClient.getQueryData<t.TGetUserPromptPreferencesResponse>([
QueryKeys.userPromptPreferences,
]);
// Optimistically update the rankings
if (previousPreferences) {
queryClient.setQueryData<t.TGetUserPromptPreferencesResponse>(
[QueryKeys.userPromptPreferences],
{
...previousPreferences,
rankings: variables.rankings,
},
);
}
if (onMutate) {
return onMutate(variables);
}
return { previousPreferences };
},
onError: (err, variables, context) => {
// Revert optimistic update on error
if (context?.previousPreferences) {
queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences);
}
if (onError) {
onError(err, variables, context);
}
},
onSuccess: (response, variables, context) => {
// Don't automatically invalidate queries to prevent infinite loops
// The optimistic update in onMutate handles the UI update
// Manual invalidation can be done by components when needed
if (onSuccess) {
onSuccess(response, variables, context);
}
},
});
};
export const useGetUserPromptPreferences = () => {
return useQuery({
queryKey: [QueryKeys.userPromptPreferences],
queryFn: () => dataService.getUserPromptPreferences(),
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false, // Prevent refetch on window focus
refetchOnMount: false, // Prevent refetch on component mount
});
};

View File

@@ -2,15 +2,16 @@ import {
QueryKeys,
dataService,
EModelEndpoint,
isAgentsEndpoint,
defaultOrderQuery,
defaultAssistantsVersion,
} from 'librechat-data-provider';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type {
InfiniteData,
UseInfiniteQueryOptions,
QueryObserverResult,
UseQueryOptions,
InfiniteData,
} from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import type {
@@ -203,7 +204,7 @@ export const useAvailableToolsQuery = <TData = t.TPlugin[]>(
const keyExpiry = queryClient.getQueryData<TCheckUserKeyResponse>([QueryKeys.name, endpoint]);
const userProvidesKey = !!endpointsConfig?.[endpoint]?.userProvide;
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
const enabled = !!endpointsConfig?.[endpoint] && keyProvided;
const enabled = isAgentsEndpoint(endpoint) ? true : !!endpointsConfig?.[endpoint] && keyProvided;
const version: string | number | undefined =
endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint];
return useQuery<t.TPlugin[], unknown, TData>(

View File

@@ -1,2 +1,4 @@
export { default as useAgentsMap } from './useAgentsMap';
export { default as useSelectAgent } from './useSelectAgent';
export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { AgentCapabilities } from 'librechat-data-provider';
interface AgentCapabilitiesResult {
toolsEnabled: boolean;
actionsEnabled: boolean;
artifactsEnabled: boolean;
ocrEnabled: boolean;
fileSearchEnabled: boolean;
webSearchEnabled: boolean;
codeEnabled: boolean;
}
export default function useAgentCapabilities(
capabilities: AgentCapabilities[] | undefined,
): AgentCapabilitiesResult {
const toolsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.tools) ?? false,
[capabilities],
);
const actionsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.actions) ?? false,
[capabilities],
);
const artifactsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.artifacts) ?? false,
[capabilities],
);
const ocrEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.ocr) ?? false,
[capabilities],
);
const fileSearchEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.file_search) ?? false,
[capabilities],
);
const webSearchEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.web_search) ?? false,
[capabilities],
);
const codeEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.execute_code) ?? false,
[capabilities],
);
return {
ocrEnabled,
codeEnabled,
toolsEnabled,
actionsEnabled,
artifactsEnabled,
webSearchEnabled,
fileSearchEnabled,
};
}

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEndpointsConfig, TConfig } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
interface UseGetAgentsConfigOptions {
endpointsConfig?: TEndpointsConfig;
}
export default function useGetAgentsConfig(options?: UseGetAgentsConfigOptions): {
agentsConfig?: TAgentsEndpoint | null;
endpointsConfig?: TEndpointsConfig | null;
} {
const { endpointsConfig: providedConfig } = options || {};
const { data: queriedConfig } = useGetEndpointsQuery({
enabled: !providedConfig,
});
const endpointsConfig = providedConfig || queriedConfig;
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]);
return { agentsConfig, endpointsConfig };
}

View File

@@ -6,6 +6,7 @@ import {
QueryKeys,
ContentTypes,
EModelEndpoint,
isAgentsEndpoint,
parseCompactConvo,
replaceSpecialVars,
isAssistantsEndpoint,
@@ -24,7 +25,6 @@ import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import store, { useGetEphemeralAgent } from '~/store';
import { getArtifactsMode } from '~/utils/artifacts';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import { useNavigate } from 'react-router-dom';
@@ -36,15 +36,6 @@ const logChatRequest = (request: Record<string, unknown>) => {
logger.log('=====================================');
};
const usesContentStream = (endpoint: EModelEndpoint | undefined, endpointType?: string) => {
if (endpointType === EModelEndpoint.custom) {
return true;
}
if (endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI) {
return true;
}
};
export default function useChatFunctions({
index = 0,
files,
@@ -76,9 +67,6 @@ export default function useChatFunctions({
const setFilesToDelete = useSetFilesToDelete();
const getEphemeralAgent = useGetEphemeralAgent();
const isTemporary = useRecoilValue(store.isTemporary);
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const customPromptMode = useRecoilValue(store.customPromptMode);
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
@@ -93,7 +81,7 @@ export default function useChatFunctions({
messageId = null,
},
{
editedText = null,
editedContent = null,
editedMessageId = null,
isResubmission = false,
isRegenerate = false,
@@ -195,10 +183,6 @@ export default function useChatFunctions({
endpointType,
overrideConvoId,
overrideUserMessageId,
artifacts:
endpoint !== EModelEndpoint.agents
? getArtifactsMode({ codeArtifacts, includeShadcnui, customPromptMode })
: undefined,
},
convo,
) as TEndpointOption;
@@ -245,14 +229,11 @@ export default function useChatFunctions({
setFilesToDelete({});
}
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue ? generation : '';
const responseMessageId =
editedMessageId ?? (latestMessage?.messageId ? latestMessage?.messageId + '_' : null) ?? null;
const initialResponse: TMessage = {
sender: responseSender,
text: responseText,
text: '',
endpoint: endpoint ?? '',
parentMessageId: isRegenerate ? messageId : intermediateId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
@@ -272,34 +253,37 @@ export default function useChatFunctions({
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
value: '',
},
},
];
} else if (endpoint === EModelEndpoint.agents) {
initialResponse.model = conversation?.agent_id ?? '';
} else if (endpoint != null) {
initialResponse.model = isAgentsEndpoint(endpoint)
? (conversation?.agent_id ?? '')
: (conversation?.model ?? '');
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
if (editedContent && latestMessage?.content) {
initialResponse.content = cloneDeep(latestMessage.content);
const { index, text, type } = editedContent;
if (initialResponse.content && index >= 0 && index < initialResponse.content.length) {
const contentPart = initialResponse.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
}
}
} else {
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: '',
},
},
},
];
setShowStopButton(true);
} else if (usesContentStream(endpoint, endpointType)) {
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
},
},
];
setShowStopButton(true);
} else {
];
}
setShowStopButton(true);
}
@@ -316,7 +300,6 @@ export default function useChatFunctions({
endpointOption,
userMessage: {
...currentMsg,
generation,
responseMessageId,
overrideParentMessageId: isRegenerate ? messageId : null,
},
@@ -328,6 +311,7 @@ export default function useChatFunctions({
initialResponse,
isTemporary,
ephemeralAgent,
editedContent,
};
if (isRegenerate) {

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