Compare commits

..

65 Commits

Author SHA1 Message Date
Dustin Healy
b034624690 refactor: cost bar uses new cost table 2025-09-13 13:30:00 -07:00
Dustin Healy
adff605c50 feat: add target model to messages so we dont have to peek into child ai message responses every time to know what model the user sent their message to for tokenValue computations 2025-09-13 13:02:04 -07:00
Dustin Healy
465c81adee feat: add addToSet update operation to saveConvo to efficiently update modelHistory whenever a new model/endpoint combo is used 2025-09-13 12:59:33 -07:00
Dustin Healy
cb8e76e27e feat: add modelHistory to conversations
modelHistory keeps track of all the models and their respective endpoints that are used in a conversation, so we can quickly fetch the right values to populate our cost table whenever we load a conversation rather than having to recompute the list every time
2025-09-13 12:49:44 -07:00
Dustin Healy
4d9e17efe1 feat: add modelCosts to QueryKeys 2025-09-13 12:46:02 -07:00
Dustin Healy
95ebef13df refactor: swap conversationCosts query hooks out for new modelCosts query and move to react-query-service 2025-09-13 12:42:43 -07:00
Dustin Healy
4fb9d7bdff refactor: swap data-service and types to use new cost table 2025-09-13 12:26:39 -07:00
Dustin Healy
0edfecf44a refactor: swap old ConversationCosts endpoint out for much simpler 'costs' endpoint
/costs just recieves an array of { model, endpoint }  and returns the related prompt and completion token rates, leaving the actual message processing to be done later on rather than on the backend.
2025-09-13 12:19:26 -07:00
Dustin Healy
ba8c09b361 feat: stop costbox from displaying during responses and make scroll to bottom ux more consistent 2025-09-13 01:51:17 -07:00
Dustin Healy
794fe6fd11 test: add unit tests for costs endpoint with various scenarios 2025-09-13 01:51:17 -07:00
Dustin Healy
97ac52fc6c feat: add toggle for cost tracking 2025-09-13 01:51:17 -07:00
Dustin Healy
1a947607a5 feat: add localization strings for the token abbreviation 't' 2025-09-13 01:51:17 -07:00
Dustin Healy
1745708418 refactor: move arrow svgs out to their own component 2025-09-13 01:51:17 -07:00
Dustin Healy
14aedac1e1 refactor: move CostBar into its own component 2025-09-13 01:51:17 -07:00
Dustin Healy
a820d79bfc feat: pull up the cost hooks into chatView to reduce api calls 2025-09-13 01:51:17 -07:00
Dustin Healy
3b1c07ff46 feat: add frontend component for displaying convo total, per message cost and tokens, and hide total block on scroll 2025-09-13 01:51:17 -07:00
Dustin Healy
c1b0f13360 feat: add queryhook layer and invalidation so we keep tokens fresh during conversation turns 2025-09-13 01:51:17 -07:00
Dustin Healy
637bbd2e29 refactor: move endpoint up so it doesn't get eclipsed by get route for conversationId 2025-09-13 01:51:17 -07:00
Dustin Healy
30e1b421ba feat: add data-service layer 2025-09-13 01:51:17 -07:00
Dustin Healy
fb89f60470 feat: add transaction costs endpoint 2025-09-13 01:51:17 -07:00
Danny Avila
5245aeea8f 🔧 refactor: Consolidate MCP tool removal and Improve UX (#9609)
* 🔧 refactor: Consolidate MCP tool removal and Improve UX

- Removed redundant tool removal logic from MCPTool, UnconfiguredMCPTool, and UninitializedMCPTool components.
- Introduced `useRemoveMCPTool` hook to handle tool removal and toast notifications.
- Updated translation.json to include a reminder message for saving changes after tool removal.

* chore: remove unused i18n key
2025-09-12 21:37:07 -04:00
Jesse Bye
dd93db40bc ⛑️ feat: Helm serviceAccount Config (#9606) 2025-09-12 17:37:29 -04:00
Muhammad Azhdari
136cf1d5a8 ⛑️ fix: follow postgres bitnami values schema in rag-api helm chart (#7782) 2025-09-12 17:36:38 -04:00
Danny Avila
751522087a v0.8.0-rc4 (#9601)
*  v0.8.0-rc4

* chore: update jest.config.cjs to include release comment and linting

* chore: bump CONFIG_VERSION to 1.2.9
2025-09-12 13:37:10 -04:00
github-actions[bot]
7fe830acfc 🌍 i18n: Update translation.json with latest translations (#9599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 13:00:30 -04:00
Danny Avila
cdfe686987 📦 chore: bump axios to v1.12.1 (#9600) 2025-09-12 13:00:12 -04:00
Danny Avila
5b5723343c 🔍 refactor: Preserve Category in Agent Marketplace Search (#9598) 2025-09-12 11:36:42 -04:00
Sebastien Bruel
30c24a66f6 🪟 fix: Auto-fetch agents to fill Viewport in Marketplace Scroll (#9591) 2025-09-12 10:59:15 -04:00
Christian Geisler
ecf9733bc1 🐳 fix: Add missing uploads directory to Dockerfile (#9590) 2025-09-12 10:56:14 -04:00
Danny Avila
133312fb40 🔐 fix: Remove OAuth handler cleanup at Connection Change (#9589) 2025-09-12 00:34:45 -04:00
github-actions[bot]
b62ffb533c 🌍 i18n: Update translation.json with latest translations (#9586)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 00:29:47 -04:00
Danny Avila
d75fb76338 refactor: Add Effective Timeout for MCP Fetch (#9585) 2025-09-11 19:09:13 -04:00
Danny Avila
51f2d43fed 🔐 refactor: Improve MCP OAuth Event Handler Cleanup (#9584)
* 🔐 refactor: Improve MCP OAuth event handling and cleanup

* ci: MCPConnection mock with additional event handling methods
2025-09-11 18:54:43 -04:00
Danny Avila
e3a645e8fb 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure (#9583)
* 🔃 fix: Token Refresh in Browser Only, Redirect on Refresh Failure

* chore: Update import for SearchResultData and fix FormattedToolResponse type build warning
2025-09-11 16:51:40 -04:00
Danny Avila
180046a3c5 ✂️ refactor: Artifacts and Tool Callbacks to Pass UI Resources (#9581)
* ✂️ refactor: use artifacts and callbacks to pass UI resources

* chore: imports

* refactor: Update UIResource type imports and definitions across components and tests

* refactor: Update ToolCallInfo test data structure and enhance TAttachment type definition

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
2025-09-11 14:34:07 -04:00
github-actions[bot]
916742ab9d 🌍 i18n: Update translation.json with latest translations (#9570)
* 🌍 i18n: Update translation.json with latest translations

* Update drag and drop tooltip text

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-11 08:13:43 -04:00
Danny Avila
d91f34dd42 🔒 refactor: Optimize Email Domain Validation in OpenID, SAML, and Social Logins (#9567)
* refactor: Optimize Email Domain Validation in OpenID, SAML, and Social Login Strategies

    - Implemented email domain validation for user authentication in OpenID and SAML strategies, ensuring only allowed domains are processed.
    - Adjusted error messages for clarity and consistency across authentication methods.
    - Refactored social login to validate email domains before checking for existing users, improving registration flow.

* refactor: Email Domain Validation in LDAP and Social Login Strategies
2025-09-11 01:01:58 -04:00
Danny Avila
5676976564 🔒 fix: Email Domain Validation Order and Coverage (#9566) 2025-09-10 23:13:39 -04:00
Danny Avila
85aa3e7d9c 🔧 refactor: Centralize Collection Checks for Permissions Migration (#9565)
* 🔧 refactor: Centralize Collection Existence Checks for Permissions Migration

* Replace individual collection existence checks with a unified function `ensureRequiredCollectionsExist` in the database utility module.
* Update migration scripts for agents and prompts to utilize the new function, ensuring all required collections are verified for existence in a single call.
* Remove redundant collection existence logic from migration files, improving code maintainability and clarity.

* chore: import order in migration scripts

* 🔧 test: Update Token Test Cases for Realistic Scenarios

* Changed email in test data to 'user1-alt@example.com' for a more realistic scenario.
* Clarified expectation comment for token retrieval to indicate it finds the only matching token based on criteria.
2025-09-10 20:40:58 -04:00
Dustin Healy
a2ff6613c5 🪄 fix: MCP UI Renders for OAuth and Custom User Vars Servers (#9559) 2025-09-10 19:02:30 -04:00
Theo N. Truong
8d6cb5eee0 🧹 chore: Remove Unused Cache Configuration Keys (#9551)
* Remove unused STATIC_CONFIG and LIBRECHAT_YAML_CONFIG cache keys.

These cache keys were identified as dead code - they were being written to but never read from anywhere in the codebase after a recent refactor:

- STATIC_CONFIG was used as a cache namespace that stored configuration data
- LIBRECHAT_YAML_CONFIG was the key used within that namespace to store parsed YAML config
- The cache.set() operation in loadCustomConfig.js stored the config but no cache.get() operations retrieved it
- Configuration data is already handled through other mechanisms without caching

* # removed tests regarding cache
2025-09-10 19:01:44 -04:00
Federico Ruggi
31445e391a 🔖 fix: Agent Marketplace Bookmark and New Chat buttons (#9549)
* don't require conversation for bookmark button

* wrap marketplace component so it can correctly use context hooks

* chore: re-order import statement for MarketplaceProvider

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-09-10 19:01:34 -04:00
Federico Ruggi
04c3a5a861 🔌 feat: Revoke MCP OAuth Credentials (#9464)
* revocation metadata fields

* store metadata

* get client info and meta

* revoke oauth tokens

* delete flow

* uninstall oauth mcp

* revoke button

* revoke oauth refactor, add comments, test

* adjust for clarity

* test deleteFlow

* handle metadata type

* no mutation

* adjust for clarity

* styling

* restructure for clarity

* move token-specific stuff

* use mcpmanager's oauth servers

* fix typo

* fix addressing of oauth prop

* log prefix

* remove debug log
2025-09-10 18:53:34 -04:00
Federico Ruggi
5667cc9702 🏪 fix: Show Agent Builder in Marketplace (#9537)
* don't require conversation endpoint

* bump up render time a bit

* a little less
2025-09-10 18:48:17 -04:00
Theo N. Truong
c0f95f971a 🗄️ refactor: Make APP_CONFIG a Dedicated Cache Store (#9558)
- This allows use APP_CONFIG in FORCED_IN_MEMORY_CACHE_NAMESPACES
- Remove the complexity of nested namespace (e.g. we no longer have to worry about the prefix of every role key)
2025-09-10 18:46:54 -04:00
Danny Avila
f125f5bd32 🤖 refactor: Auto-validate IDs in Agent Query (#9555)
* 🤖 refactor: Auto-validate IDs in Agent Query

* chore: remove comments in useAgentToolPermissions
2025-09-10 18:38:33 -04:00
Danny Avila
f3eca8c7a7 📦 chore: bump vite to address low severity vulns (#9553)
* 📦 chore: bump `vite` to address low severity vulns

* chore: update bun.lockb to reflect dependency changes
2025-09-10 14:56:46 -04:00
github-actions[bot]
f22e5f965e 🌍 i18n: Update translation.json with latest translations (#9533)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 14:29:33 -04:00
Danny Avila
749f539dfc 📬 refactor: Improved Rendering and Localization for Drag & Drop Files (#9547)
* 📬 refactor: Improved Rendering and Localization for Drag & Drop Files

- Refactored DragDropOverlay to use memoization and props for active state management.
- Updated the overlay to always render, reducing mount/unmount overhead.
- Improved user experience with localized text for drag-and-drop instructions.
- Enhanced file handling logic in useDragHelpers for better performance and clarity.

* fix: agent data retrieval in drag helper
2025-09-10 14:27:57 -04:00
Danny Avila
1247207afe 🔒 fix: Memory Disabled Config UI Permissions (2/2) 2025-09-09 22:00:01 -04:00
Danny Avila
5c0e9d8fbb 📂 refactor: Show File Search and Code File Upload Options Based on Agent Tools (#9532) 2025-09-09 20:48:29 -04:00
Dustin Healy
957fa7a994 😶‍🌫️ refactor: Conditionally Hide Tools Dropdown (#9530) 2025-09-09 19:57:50 -04:00
Danny Avila
751c2e1d17 👻 refactor: LocalStorage Cleanup and MCP State Optimization (#9528)
* 👻 refactor: MCP Select State with Jotai Atoms

* refactor: Implement timestamp management for ChatArea localStorage entries

* refactor: Integrate MCP Server Manager into BadgeRow context and components to avoid double-calling within BadgeRow

* refactor: add try/catch

* chore: remove comment
2025-09-09 17:32:10 -04:00
Danny Avila
519645c0b0 🔻 fix: Role and System Message Handling for ChatGPT Imports (#9524)
* fix: ChatGPT import logic breaks message graph when it encounters a system message

- Implemented `findNonSystemParent` to maintain parent-child relationships by skipping system messages.
- Added a test case to ensure system messages do not disrupt the conversation flow during import.

* fix: ChatGPT import, correct sender for user messages with GPT-4 model

* fix: Enhance model name extraction for assistant messages in import process

- Updated sender assignment logic to dynamically extract model names from model slugs, improving accuracy for various GPT models.
- Added comprehensive tests to validate the extraction and formatting of model names from different model slugs, ensuring robustness in the import functionality.
2025-09-09 13:51:26 -04:00
Danny Avila
0d0a318c3c 📦 chore: Update caniuse-lite to v1.0.30001741 (#9523) 2025-09-09 09:26:15 -04:00
Danny Avila
588e0c4611 🔒 fix: Memory Disabled Config UI Permissions (#9522) 2025-09-09 09:14:40 -04:00
github-actions[bot]
79144a6365 🌍 i18n: Update translation.json with latest translations (#9515)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 09:08:28 -04:00
Danny Avila
ca53c20370 🚃 refactor: Normalize paths for Vite Config Chunking (#9513) 2025-09-08 21:53:15 -04:00
Danny Avila
d635503f49 🔐 ci: Add MCP Environment Processing tests 2025-09-08 15:38:44 -04:00
Dev
920966f895 🔐 fix: Resolve Env. Variables for MCP OAuth Manual Config (#9501)
* Added functionality to process OAuth configuration within the MCP environment.
* Implemented handling for string values in OAuth settings, ensuring proper processing of environment variables.
* Maintained original structure for non-string values to preserve existing configurations.
2025-09-08 15:29:10 -04:00
Danny Avila
c46e0d3ecc 🔒 fix: href Attribute in Email Microsoft Template 2025-09-08 14:39:00 -04:00
Dustin Healy
c6ecf0095b 🎚️ feat: Anthropic Parameter Set Support via Custom Endpoints (#9415)
* refactor: modularize openai llm config logic into new getOpenAILLMConfig function (#9412)

* ✈️ refactor: Migrate Anthropic's getLLMConfig to TypeScript (#9413)

* refactor: move tokens.js over to packages/api and update imports

* refactor: port tokens.js to typescript

* refactor: move helpers.js over to packages/api and update imports

* refactor: port helpers.js to typescript

* refactor: move anthropic/llm.js over to packages/api and update imports

* refactor: port anthropic/llm.js to typescript with supporting types in types/anthropic.ts and updated tests in llm.spec.js

* refactor: move llm.spec.js over to packages/api and update import

* refactor: port llm.spec.js over to typescript

* 📝  Add Prompt Parameter Support for Anthropic Custom Endpoints (#9414)

feat: add anthropic llm config support for openai-like (custom) endpoints

* fix: missed compiler / type issues from addition of getAnthropicLLMConfig

* refactor: update tokens.ts to export constants and functions, enhance type definitions, and adjust default values

* WIP: first pass, decouple `llmConfig` from `configOptions`

* chore: update import path for OpenAI configuration from 'llm' to 'config'

* refactor: enhance type definitions for ThinkingConfig and update modelOptions in AnthropicConfigOptions

* refactor: cleanup type, introduce openai transform from alt provider

* chore: integrate removeNullishValues in Google llmConfig and update OpenAI exports

* chore: bump version of @librechat/api to 1.3.5 in package.json and package-lock.json

* refactor: update customParams type in OpenAIConfigOptions to use TConfig['customParams']

* refactor: enhance transformToOpenAIConfig to include fromEndpoint and improve config extraction

* refactor: conform userId field for anthropic/openai, cleanup anthropic typing

* ci: add backward compatibility tests for getOpenAIConfig with various endpoints and configurations

* ci: replace userId with user in clientOptions for getLLMConfig

* test: add Azure OpenAI endpoint tests for various configurations in getOpenAIConfig

* refactor: defaultHeaders retrieval for prompt caching for anthropic-based custom endpoint (litellm)

* test: add unit tests for getOpenAIConfig with various Anthropic model configurations

* test: enhance Anthropic compatibility tests with addParams and dropParams handling

* chore: update @librechat/agents dependency to version 2.4.78 in package.json and package-lock.json

* chore: update @librechat/agents dependency to version 2.4.79 in package.json and package-lock.json

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-08 14:35:29 -04:00
Danny Avila
7de6f6e44c ⚙️ chore: Update Build Config due to Windows Tests (#9511)
* chore: remove `rollup-plugin-generate-package-json`

* chore: increase maximum file size to cache in Vite configuration for windows builds
2025-09-08 14:16:49 -04:00
Danny Avila
035f85c3ba 🧪 ci: Tests for Anthropic and OpenAI LLM Configuration (#9484)
* fix: freq. and pres. penalty use camelcase

* ci: OpenAI Configuration Tests

* ci: Enhance OpenAI Configuration Tests with Azure and Custom Endpoint Scenarios

* Added integration tests for OpenAI and Azure configurations simulating various initialization scenarios.
* Updated OpenAIConfigOptions to allow null values for reverseProxyUrl and proxy.
* Improved handling of reasoning parameters in tests for both OpenAI and Azure setups.
* Ensured robust error handling for missing API keys and malformed configurations.
* Optimized performance for large parameter sets in configuration.

* test: Add comprehensive integration tests for Anthropic LLM configuration

* Introduced real usage integration tests for various Anthropic endpoint configurations, including handling of proxy and reverse proxy setups.
* Implemented model-specific scenarios for Claude-3.7 and web search functionality.
* Enhanced error handling for missing user IDs and large parameter sets.
* Validated parameter logic, including default values, boundary conditions, and type handling for numeric and array parameters.
* Ensured proper exclusion of system options from model options and maintained expected behavior across different model variations.
2025-09-06 09:42:12 -04:00
Daniel Andersen
6f6a34d126 🔗 feat: Custom Jina API URL for Web Search Reranking (#9236)
* feat: added support for custom JINA_API_URL

* fixed tests

* chore: Update @librechat/agents dependency to version 2.4.77 in package-lock.json and package.json files

* fix: Update Jina API URL to use environment variable in configuration files

* Refactor AppService, web.ts, and config.ts to replace hardcoded Jina API URL with an environment variable placeholder.
* Ensure consistency across tests and configuration for Jina API URL.

* chore: alphabetical order translation.json

* fix: alphabetical order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-09-06 08:39:20 -04:00
182 changed files with 10411 additions and 2555 deletions

View File

@@ -690,8 +690,8 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys (e.g., STATIC_CONFIG,ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=STATIC_CONFIG,ROLES
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
#==================================================#
# Others #

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc3
# v0.8.0-rc4
# Base node image
FROM node:20-alpine AS node
@@ -30,7 +30,7 @@ RUN \
# Allow mounting of these files, which have no default
touch .env ; \
# Create directories for the volumes to inherit the correct permissions
mkdir -p /app/client/public/images /app/api/logs ; \
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
@@ -44,8 +44,6 @@ RUN \
npm prune --production; \
npm cache clean --force
RUN mkdir -p /app/client/public/images /app/api/logs
# Node API setup
EXPOSE 3080
ENV HOST=0.0.0.0

View File

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

View File

@@ -75,6 +75,7 @@
- 🔍 **Web Search**:
- Search the internet and retrieve relevant information to enhance your AI context
- Combines search providers, content scrapers, and result rerankers for optimal results
- **Customizable Jina Reranking**: Configure custom Jina API URLs for reranking services
- **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
- 🪄 **Generative UI with Code Artifacts**:

View File

@@ -233,6 +233,7 @@ class BaseClient {
sender: 'User',
text,
isCreatedByUser: true,
targetModel: this.modelOptions?.model ?? this.model,
};
}

View File

@@ -157,12 +157,11 @@ describe('cacheConfig', () => {
describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => {
test('should parse comma-separated cache keys correctly', () => {
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, STATIC_CONFIG ,MESSAGES ';
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES ';
const { cacheConfig } = require('./cacheConfig');
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([
'ROLES',
'STATIC_CONFIG',
'MESSAGES',
]);
});

View File

@@ -31,8 +31,8 @@ const namespaces = {
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),

View File

@@ -112,8 +112,17 @@ module.exports = {
update.expiredAt = null;
}
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
/** @type {{ $set: Partial<TConversation>; $addToSet?: Record<string, any>; $unset?: Record<keyof TConversation, number> }} */
const updateOperation = { $set: update };
if (convo.model && convo.endpoint) {
updateOperation.$addToSet = {
modelHistory: {
model: convo.model,
endpoint: convo.endpoint,
},
};
}
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
updateOperation.$unset = metadata.unsetFields;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -49,14 +49,14 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.76",
"@librechat/agents": "^2.4.79",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.17.1",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"axios": "^1.12.1",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",

View File

@@ -1,5 +1,10 @@
const { logger } = require('@librechat/data-schemas');
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
const {
webSearchKeys,
extractWebSearchEnvVars,
normalizeHttpError,
MCPTokenStorage,
} = require('@librechat/api');
const {
getFiles,
updateUser,
@@ -16,11 +21,17 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { Tools, Constants, FileSources } = require('librechat-data-provider');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User } = require('~/db/models');
const { Transaction, Balance, User, Token } = require('~/db/models');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
const { MCPOAuthHandler } = require('@librechat/api');
const { getFlowStateManager } = require('~/config');
const { CacheKeys } = require('librechat-data-provider');
const { getLogStores } = require('~/cache');
const { clearMCPServerTools } = require('~/server/services/Config/mcpToolsCache');
const { findToken } = require('~/models');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -162,6 +173,15 @@ const updateUserPluginsController = async (req, res) => {
);
({ status, message } = normalizeHttpError(authService));
}
try {
// if the MCP server uses OAuth, perform a full cleanup and token revocation
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
} catch (error) {
logger.error(
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
error,
);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
@@ -269,6 +289,97 @@ const resendVerificationController = async (req, res) => {
}
};
/**
* OAuth MCP specific uninstall logic
*/
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
// this is not an MCP server, so nothing to do here
return;
}
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
const mcpManager = getMCPManager(userId);
const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
if (!mcpManager.getOAuthServers().has(serverName)) {
// this server does not use OAuth, so nothing to do here as well
return;
}
// 1. get client info used for revocation (client id, secret)
const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
userId,
serverName,
findToken,
});
if (clientTokenData == null) {
return;
}
const { clientInfo, clientMetadata } = clientTokenData;
// 2. get decrypted tokens before deletion
const tokens = await MCPTokenStorage.getTokens({
userId,
serverName,
findToken,
});
// 3. revoke OAuth tokens at the provider
const revocationEndpoint =
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
const revocationEndpointAuthMethodsSupported =
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
if (tokens?.access_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
});
} catch (error) {
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
}
}
if (tokens?.refresh_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
});
} catch (error) {
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
}
}
// 4. delete tokens from the DB after revocation attempts
await MCPTokenStorage.deleteUserTokens({
userId,
serverName,
deleteToken: async (filter) => {
await Token.deleteOne(filter);
},
});
// 5. clear the flow state for the OAuth tokens
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
await flowManager.deleteFlow(flowId, 'mcp_oauth');
// 6. clear the tools cache for the server
await clearMCPServerTools({ userId, serverName });
};
module.exports = {
getUserController,
getTermsStatusController,

View File

@@ -0,0 +1,342 @@
const { Tools } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-id'),
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
Providers: { GOOGLE: 'google' },
GraphEvents: {},
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked logger
logger = require('@librechat/data-schemas').logger;
// Now require the module after all mocks are set up
const callbacks = require('../callbacks');
createToolEndCallback = callbacks.createToolEndCallback;
req = {
user: { id: 'user123' },
};
res = {
headersSent: false,
write: jest.fn(),
};
artifactPromises = [];
});
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
const results = await Promise.all(artifactPromises);
// When headers are not sent, it returns attachment without writing
expect(res.write).not.toHaveBeenCalled();
const attachment = results[0];
expect(attachment).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
});
});
it('should write to response when headers are already sent', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'carousel', items: [] },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(res.write).toHaveBeenCalled();
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'carousel', items: [] },
},
});
});
it('should handle errors when processing ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
// Mock res.write to throw an error
res.headersSent = true;
res.write.mockImplementation(() => {
throw new Error('Write failed');
});
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'test' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(logger.error).toHaveBeenCalledWith(
'Error processing artifact content:',
expect.any(Error),
);
expect(results[0]).toBeNull();
});
it('should handle multiple artifacts including ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'chart', data: [] },
},
},
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
// Both ui_resources and web_search should be processed
expect(artifactPromises).toHaveLength(2);
expect(results).toHaveLength(2);
// Check ui_resources attachment
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
expect(uiResourceAttachment).toBeTruthy();
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
0: { type: 'chart', data: [] },
});
// Check web_search attachment
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
expect(webSearchAttachment).toBeTruthy();
expect(webSearchAttachment[Tools.web_search]).toEqual({
results: ['result1', 'result2'],
});
});
it('should not process artifacts when output has no artifacts', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
content: 'Some regular content',
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty ui_resources data object', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {},
});
});
it('should handle ui_resources with complex nested data', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const complexData = {
0: {
type: 'form',
fields: [
{ name: 'field1', type: 'text', required: true },
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
],
nested: {
deep: {
value: 123,
array: [1, 2, 3],
},
},
},
};
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: complexData,
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
});

View File

@@ -265,6 +265,30 @@ function createToolEndCallback({ req, res, artifactPromises }) {
);
}
// TODO: a lot of duplicated code in createToolEndCallback
// we should refactor this to use a helper function in a follow-up PR
if (output.artifact[Tools.ui_resources]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.ui_resources,
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
};
if (!res.headersSent) {
return attachment;
}
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact[Tools.web_search]) {
artifactPromises.push(
(async () => {

View File

@@ -872,11 +872,10 @@ class AgentClient extends BaseClient {
if (agent.useLegacyContent === true) {
messages = formatContentStrings(messages);
}
if (
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
'prompt-caching',
)
) {
const defaultHeaders =
agent.model_parameters?.clientOptions?.defaultHeaders ??
agent.model_parameters?.configuration?.defaultHeaders;
if (defaultHeaders?.['anthropic-beta']?.includes('prompt-caching')) {
messages = addCacheControl(messages);
}

View File

@@ -11,18 +11,25 @@ const { getAppConfig } = require('~/server/services/Config');
* @param {Object} res - Express response object.
* @param {Function} next - Next middleware function.
*
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the domain's email is allowed
* @returns {Promise<void>} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
*/
const checkDomainAllowed = async (req, res, next = () => {}) => {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
});
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
return res.redirect('/login');
} else {
return next();
const checkDomainAllowed = async (req, res, next) => {
try {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
});
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
res.redirect('/login');
return;
}
next();
} catch (error) {
logger.error('[checkDomainAllowed] Error checking domain:', error);
res.redirect('/login');
}
};

View File

@@ -0,0 +1,680 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
createMethods: jest.fn(() => ({})),
createModels: jest.fn(() => ({})),
}));
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
validateMessageReq: (req, res, next) => next(),
}));
jest.mock('~/models', () => ({
getConvo: jest.fn(),
saveConvo: jest.fn(),
saveMessage: jest.fn(),
getMessage: jest.fn(),
getMessages: jest.fn(),
updateMessage: jest.fn(),
deleteMessages: jest.fn(),
}));
jest.mock('~/db/models', () => {
let User, Message, Transaction, Conversation;
return {
get User() {
return User;
},
get Message() {
return Message;
},
get Transaction() {
return Transaction;
},
get Conversation() {
return Conversation;
},
setUser: (model) => {
User = model;
},
setMessage: (model) => {
Message = model;
},
setTransaction: (model) => {
Transaction = model;
},
setConversation: (model) => {
Conversation = model;
},
};
});
describe('Costs Endpoint', () => {
let app;
let mongoServer;
let messagesRouter;
let User, Message, Transaction, Conversation;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const userSchema = new mongoose.Schema({
_id: String,
name: String,
email: String,
});
const conversationSchema = new mongoose.Schema({
conversationId: String,
user: String,
title: String,
createdAt: Date,
});
const messageSchema = new mongoose.Schema({
messageId: String,
conversationId: String,
user: String,
isCreatedByUser: Boolean,
tokenCount: Number,
createdAt: Date,
});
const transactionSchema = new mongoose.Schema({
conversationId: String,
user: String,
tokenType: String,
tokenValue: Number,
createdAt: Date,
});
User = mongoose.model('User', userSchema);
Conversation = mongoose.model('Conversation', conversationSchema);
Message = mongoose.model('Message', messageSchema);
Transaction = mongoose.model('Transaction', transactionSchema);
const dbModels = require('~/db/models');
dbModels.setUser(User);
dbModels.setMessage(Message);
dbModels.setTransaction(Transaction);
dbModels.setConversation(Conversation);
require('~/db/models');
try {
messagesRouter = require('../messages');
} catch (error) {
console.error('Error loading messages router:', error);
throw error;
}
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = { id: 'test-user-id' };
next();
});
app.use('/api/messages', messagesRouter);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await User.deleteMany({});
await Conversation.deleteMany({});
await Message.deleteMany({});
await Transaction.deleteMany({});
});
describe('GET /:conversationId/costs', () => {
const conversationId = 'test-conversation-123';
const userId = 'test-user-id';
it('should return cost data for valid conversation', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const aiMessage = new Message({
messageId: 'ai-msg-1',
conversationId,
user: userId,
isCreatedByUser: false,
tokenCount: 150,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage.save(), aiMessage.save()]);
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const completionTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'completion',
tokenValue: 750000,
createdAt: new Date('2024-01-01T10:01:30Z'),
});
await Promise.all([promptTransaction.save(), completionTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
conversationId,
totals: {
prompt: { usd: 0.5, tokenCount: 100 },
completion: { usd: 0.75, tokenCount: 150 },
total: { usd: 1.25, tokenCount: 250 },
},
perMessage: [
{ messageId: 'user-msg-1', tokenType: 'prompt', tokenCount: 100, usd: 0.5 },
{ messageId: 'ai-msg-1', tokenType: 'completion', tokenCount: 150, usd: 0.75 },
],
});
});
it('should return empty data for conversation with no messages', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
conversationId,
totals: {
prompt: { usd: 0, tokenCount: 0 },
completion: { usd: 0, tokenCount: 0 },
total: { usd: 0, tokenCount: 0 },
},
perMessage: [],
});
});
it('should handle messages without transactions', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const aiMessage = new Message({
messageId: 'ai-msg-1',
conversationId,
user: userId,
isCreatedByUser: false,
tokenCount: 150,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage.save(), aiMessage.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0);
expect(response.body.totals.completion.usd).toBe(0);
expect(response.body.totals.total.usd).toBe(0);
});
it('should aggregate multiple transactions correctly', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction1 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 300000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const promptTransaction2 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 200000,
createdAt: new Date('2024-01-01T10:00:45Z'),
});
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.perMessage[0].usd).toBe(0.5);
});
it('should handle null tokenCount values', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: null,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.tokenCount).toBe(0);
});
it('should handle null tokenValue in transactions', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: null,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await promptTransaction.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0);
});
it('should handle negative tokenValue using Math.abs', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: -500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await promptTransaction.save();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
});
it('should filter by user correctly', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const otherUserId = 'other-user-id';
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const otherUserMessage = new Message({
messageId: 'other-user-msg-1',
conversationId,
user: otherUserId,
isCreatedByUser: true,
tokenCount: 200,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await Promise.all([userMessage.save(), otherUserMessage.save()]);
const userTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const otherUserTransaction = new Transaction({
conversationId,
user: otherUserId,
tokenType: 'prompt',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await Promise.all([userTransaction.save(), otherUserTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.perMessage).toHaveLength(1);
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
});
it('should filter transactions by tokenType', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
await userMessage.save();
const promptTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const otherTransaction = new Transaction({
conversationId,
user: userId,
tokenType: 'other',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
await Promise.all([promptTransaction.save(), otherTransaction.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.totals.prompt.usd).toBe(0.5);
expect(response.body.totals.completion.usd).toBe(0);
expect(response.body.totals.total.usd).toBe(0.5);
});
it('should map transactions to messages chronologically', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
const userMessage1 = new Message({
messageId: 'user-msg-1',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 100,
createdAt: new Date('2024-01-01T10:00:00Z'),
});
const userMessage2 = new Message({
messageId: 'user-msg-2',
conversationId,
user: userId,
isCreatedByUser: true,
tokenCount: 200,
createdAt: new Date('2024-01-01T10:01:00Z'),
});
await Promise.all([userMessage1.save(), userMessage2.save()]);
const promptTransaction1 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 500000,
createdAt: new Date('2024-01-01T10:00:30Z'),
});
const promptTransaction2 = new Transaction({
conversationId,
user: userId,
tokenType: 'prompt',
tokenValue: 1000000,
createdAt: new Date('2024-01-01T10:01:30Z'),
});
await Promise.all([promptTransaction1.save(), promptTransaction2.save()]);
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(200);
expect(response.body.perMessage).toHaveLength(2);
expect(response.body.perMessage[0].messageId).toBe('user-msg-1');
expect(response.body.perMessage[0].usd).toBe(0.5);
expect(response.body.perMessage[1].messageId).toBe('user-msg-2');
expect(response.body.perMessage[1].usd).toBe(1.0);
});
it('should handle database errors', async () => {
const { getConvo } = require('~/models');
getConvo.mockResolvedValue({
conversationId,
user: userId,
title: 'Test Conversation',
});
const conversation = new Conversation({
conversationId,
user: userId,
title: 'Test Conversation',
createdAt: new Date('2024-01-01T09:00:00Z'),
});
await conversation.save();
await mongoose.connection.close();
const response = await request(app).get(`/api/messages/${conversationId}/costs`);
expect(response.status).toBe(500);
expect(response.body).toHaveProperty('error');
});
});
});

View File

@@ -11,6 +11,7 @@ const {
} = require('~/models');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { tokenValues, getValueKey, defaultRate } = require('~/models/tx');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { getConvosQueried } = require('~/models/Conversation');
const { countTokens } = require('~/server/utils');
@@ -160,6 +161,41 @@ router.post('/artifact/:messageId', async (req, res) => {
}
});
/**
* POST /costs
* Get cost information for models in modelHistory array
*/
router.post('/costs', async (req, res) => {
try {
const { modelHistory } = req.body;
if (!Array.isArray(modelHistory)) {
return res.status(400).json({ error: 'modelHistory must be an array' });
}
const modelCostTable = {};
modelHistory.forEach((modelEntry) => {
if (modelEntry && typeof modelEntry === 'object' && modelEntry.model && modelEntry.endpoint) {
const { model, endpoint } = modelEntry;
const valueKey = getValueKey(model, endpoint);
const pricing = tokenValues[valueKey];
modelCostTable[model] = {
prompt: pricing?.prompt ?? defaultRate,
completion: pricing?.completion ?? defaultRate,
};
}
});
res.status(200).json({ modelCostTable });
} catch (error) {
logger.error('Error fetching model costs:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
router.get('/:conversationId', validateMessageReq, async (req, res) => {
try {

View File

@@ -26,9 +26,12 @@ const domains = {
router.use(logHeaders);
router.use(loginLimiter);
const oauthHandler = async (req, res) => {
const oauthHandler = async (req, res, next) => {
try {
await checkDomainAllowed(req, res);
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
@@ -46,6 +49,7 @@ const oauthHandler = async (req, res) => {
res.redirect(domains.client);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
@@ -79,6 +83,7 @@ router.get(
scope: ['openid', 'profile', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -104,6 +109,7 @@ router.get(
profileFields: ['id', 'email', 'name'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -125,6 +131,7 @@ router.get(
session: false,
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -148,6 +155,7 @@ router.get(
scope: ['user:email', 'read:user'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -171,6 +179,7 @@ router.get(
scope: ['identify', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
@@ -192,6 +201,7 @@ router.post(
session: false,
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);

View File

@@ -152,6 +152,7 @@ describe('AppService', () => {
webSearch: expect.objectContaining({
safeSearch: 1,
jinaApiKey: '${JINA_API_KEY}',
jinaApiUrl: '${JINA_API_URL}',
cohereApiKey: '${COHERE_API_KEY}',
serperApiKey: '${SERPER_API_KEY}',
searxngApiKey: '${SEARXNG_API_KEY}',

View File

@@ -3,12 +3,12 @@ const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, checkEmailConfig } = require('@librechat/api');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
findToken,
createUser,
updateUser,
findToken,
countUsers,
getUserById,
findSession,
@@ -181,6 +181,14 @@ const registerUser = async (user, additionalData = {}) => {
let newUserId;
try {
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
@@ -195,14 +203,6 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 200, message: genericVerificationMessage };
}
const appConfig = await getAppConfig({ role: user.role });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
@@ -252,6 +252,13 @@ const registerUser = async (user, additionalData = {}) => {
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return error;
}
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();

View File

@@ -4,6 +4,8 @@ const AppService = require('~/server/services/AppService');
const { setCachedTools } = require('./getCachedTools');
const getLogStores = require('~/cache/getLogStores');
const BASE_CONFIG_KEY = '_BASE_';
/**
* Get the app configuration based on user context
* @param {Object} [options]
@@ -14,8 +16,8 @@ const getLogStores = require('~/cache/getLogStores');
async function getAppConfig(options = {}) {
const { role, refresh } = options;
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cacheKey = role ? `${CacheKeys.APP_CONFIG}:${role}` : CacheKeys.APP_CONFIG;
const cache = getLogStores(CacheKeys.APP_CONFIG);
const cacheKey = role ? role : BASE_CONFIG_KEY;
if (!refresh) {
const cached = await cache.get(cacheKey);
@@ -24,7 +26,7 @@ async function getAppConfig(options = {}) {
}
}
let baseConfig = await cache.get(CacheKeys.APP_CONFIG);
let baseConfig = await cache.get(BASE_CONFIG_KEY);
if (!baseConfig) {
logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...');
baseConfig = await AppService();
@@ -37,7 +39,7 @@ async function getAppConfig(options = {}) {
await setCachedTools(baseConfig.availableTools, { isGlobal: true });
}
await cache.set(CacheKeys.APP_CONFIG, baseConfig);
await cache.set(BASE_CONFIG_KEY, baseConfig);
}
// For now, return the base config

View File

@@ -119,10 +119,6 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
.filter((endpoint) => endpoint.customParams)
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
if (customConfig.cache) {
const cache = getLogStores(CacheKeys.STATIC_CONFIG);
await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig);
}
if (result.data.modelSpecs) {
customConfig.modelSpecs = result.data.modelSpecs;

View File

@@ -48,16 +48,11 @@ const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores');
describe('loadCustomConfig', () => {
const mockSet = jest.fn();
const mockCache = { set: mockSet };
beforeEach(() => {
jest.resetAllMocks();
delete process.env.CONFIG_PATH;
getLogStores.mockReturnValue(mockCache);
});
it('should return null and log error if remote config fetch fails', async () => {
@@ -94,7 +89,6 @@ describe('loadCustomConfig', () => {
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null and log if config schema validation fails', async () => {
@@ -134,7 +128,6 @@ describe('loadCustomConfig', () => {
axios.get.mockResolvedValue({ data: mockConfig });
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null if the remote config file is not found', async () => {
@@ -168,7 +161,6 @@ describe('loadCustomConfig', () => {
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
expect(mockSet).not.toHaveBeenCalled();
});
it('should log the loaded custom config', async () => {

View File

@@ -40,7 +40,6 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
clientOptions = Object.assign(
{
proxy: PROXY ?? null,
userId: req.user.id,
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
modelOptions: endpointOption?.model_parameters ?? {},
},
@@ -49,6 +48,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (overrideModel) {
clientOptions.modelOptions.model = overrideModel;
}
clientOptions.modelOptions.user = req.user.id;
return getLLMConfig(anthropicApiKey, clientOptions);
}

View File

@@ -88,6 +88,7 @@ async function saveUserMessage(req, params) {
parentMessageId: params.parentMessageId ?? Constants.NO_PARENT,
/* For messages, use the assistant_id instead of model */
model: params.assistant_id,
targetModel: params.model,
thread_id: params.thread_id,
sender: 'User',
text: params.text,

View File

@@ -229,7 +229,7 @@
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href=&quot;{{verificationLink}}&quot;" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{verificationLink}}" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{verificationLink}}'
target='_blank'

View File

@@ -1,9 +1,9 @@
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
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');
/**
* Returns the appropriate importer function based on the provided JSON data.
@@ -212,6 +212,29 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
}
}
/**
* Helper function to find the nearest non-system parent
* @param {string} parentId - The ID of the parent message.
* @returns {string} The ID of the nearest non-system parent message.
*/
const findNonSystemParent = (parentId) => {
if (!parentId || !messageMap.has(parentId)) {
return Constants.NO_PARENT;
}
const parentMapping = conv.mapping[parentId];
if (!parentMapping?.message) {
return Constants.NO_PARENT;
}
/* If parent is a system message, traverse up to find the nearest non-system parent */
if (parentMapping.message.author?.role === 'system') {
return findNonSystemParent(parentMapping.parent);
}
return messageMap.get(parentId);
};
// Create and save messages using the mapped IDs
const messages = [];
for (const [id, mapping] of Object.entries(conv.mapping)) {
@@ -220,23 +243,27 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
messageMap.delete(id);
continue;
} else if (role === 'system') {
messageMap.delete(id);
// Skip system messages but keep their ID in messageMap for parent references
continue;
}
const newMessageId = messageMap.get(id);
const parentMessageId =
mapping.parent && messageMap.has(mapping.parent)
? messageMap.get(mapping.parent)
: Constants.NO_PARENT;
const parentMessageId = findNonSystemParent(mapping.parent);
const messageText = formatMessageText(mapping.message);
const isCreatedByUser = role === 'user';
let sender = isCreatedByUser ? 'user' : 'GPT-3.5';
let sender = isCreatedByUser ? 'user' : 'assistant';
const model = mapping.message.metadata.model_slug || openAISettings.model.default;
if (model.includes('gpt-4')) {
sender = 'GPT-4';
if (!isCreatedByUser) {
/** Extracted model name from model slug */
const gptMatch = model.match(/gpt-(.+)/i);
if (gptMatch) {
sender = `GPT-${gptMatch[1]}`;
} else {
sender = model || 'assistant';
}
}
messages.push({

View File

@@ -99,6 +99,404 @@ describe('importChatGptConvo', () => {
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
});
it('should handle system messages without breaking parent-child relationships', async () => {
/**
* Test data that reproduces message graph "breaking" when it encounters a system message
*/
const testData = [
{
title: 'System Message Parent Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['user-msg-1'],
},
'user-msg-1': {
id: 'user-msg-1',
message: {
id: 'user-msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['First user message'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'root-node',
children: ['assistant-msg-1'],
},
'assistant-msg-1': {
id: 'assistant-msg-1',
message: {
id: 'assistant-msg-1',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['First assistant response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-1',
children: ['system-msg'],
},
'system-msg': {
id: 'system-msg',
message: {
id: 'system-msg',
author: { role: 'system' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['System message in middle'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'assistant-msg-1',
children: ['user-msg-2'],
},
'user-msg-2': {
id: 'user-msg-2',
message: {
id: 'user-msg-2',
author: { role: 'user' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['Second user message'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'system-msg',
children: ['assistant-msg-2'],
},
'assistant-msg-2': {
id: 'assistant-msg-2',
message: {
id: 'assistant-msg-2',
author: { role: 'assistant' },
create_time: 1714585035.150442,
content: { content_type: 'text', parts: ['Second assistant response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-2',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
/** 2 user messages + 2 assistant messages (system message should be skipped) */
const expectedMessages = 4;
expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedMessages);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
const messageMap = new Map();
savedMessages.forEach((msg) => {
messageMap.set(msg.text, msg);
});
const firstUser = messageMap.get('First user message');
const firstAssistant = messageMap.get('First assistant response');
const secondUser = messageMap.get('Second user message');
const secondAssistant = messageMap.get('Second assistant response');
expect(firstUser).toBeDefined();
expect(firstAssistant).toBeDefined();
expect(secondUser).toBeDefined();
expect(secondAssistant).toBeDefined();
expect(firstUser.parentMessageId).toBe(Constants.NO_PARENT);
expect(firstAssistant.parentMessageId).toBe(firstUser.messageId);
// This is the key test: second user message should have first assistant as parent
// (not NO_PARENT which would indicate the system message broke the chain)
expect(secondUser.parentMessageId).toBe(firstAssistant.messageId);
expect(secondAssistant.parentMessageId).toBe(secondUser.messageId);
});
it('should maintain correct sender for user messages regardless of GPT-4 model', async () => {
/**
* Test data with GPT-4 model to ensure user messages keep 'user' sender
*/
const testData = [
{
title: 'GPT-4 Sender Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['user-msg-1'],
},
'user-msg-1': {
id: 'user-msg-1',
message: {
id: 'user-msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['User message with GPT-4'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'root-node',
children: ['assistant-msg-1'],
},
'assistant-msg-1': {
id: 'assistant-msg-1',
message: {
id: 'assistant-msg-1',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['Assistant response with GPT-4'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'user-msg-1',
children: ['user-msg-2'],
},
'user-msg-2': {
id: 'user-msg-2',
message: {
id: 'user-msg-2',
author: { role: 'user' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['Another user message with GPT-4o-mini'] },
metadata: { model_slug: 'gpt-4o-mini' },
},
parent: 'assistant-msg-1',
children: ['assistant-msg-2'],
},
'assistant-msg-2': {
id: 'assistant-msg-2',
message: {
id: 'assistant-msg-2',
author: { role: 'assistant' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['Assistant response with GPT-3.5'] },
metadata: { model_slug: 'gpt-3.5-turbo' },
},
parent: 'user-msg-2',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
const userMsg1 = savedMessages.find((msg) => msg.text === 'User message with GPT-4');
const assistantMsg1 = savedMessages.find((msg) => msg.text === 'Assistant response with GPT-4');
const userMsg2 = savedMessages.find(
(msg) => msg.text === 'Another user message with GPT-4o-mini',
);
const assistantMsg2 = savedMessages.find(
(msg) => msg.text === 'Assistant response with GPT-3.5',
);
expect(userMsg1.sender).toBe('user');
expect(userMsg1.isCreatedByUser).toBe(true);
expect(userMsg1.model).toBe('gpt-4');
expect(userMsg2.sender).toBe('user');
expect(userMsg2.isCreatedByUser).toBe(true);
expect(userMsg2.model).toBe('gpt-4o-mini');
expect(assistantMsg1.sender).toBe('GPT-4');
expect(assistantMsg1.isCreatedByUser).toBe(false);
expect(assistantMsg1.model).toBe('gpt-4');
expect(assistantMsg2.sender).toBe('GPT-3.5-turbo');
expect(assistantMsg2.isCreatedByUser).toBe(false);
expect(assistantMsg2.model).toBe('gpt-3.5-turbo');
});
it('should correctly extract and format model names from various model slugs', async () => {
/**
* Test data with various model slugs to test dynamic model identifier extraction
*/
const testData = [
{
title: 'Dynamic Model Identifier Test',
create_time: 1714585031.148505,
update_time: 1714585060.879308,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['msg-1'],
},
'msg-1': {
id: 'msg-1',
message: {
id: 'msg-1',
author: { role: 'user' },
create_time: 1714585031.150442,
content: { content_type: 'text', parts: ['Test message'] },
metadata: {},
},
parent: 'root-node',
children: ['msg-2', 'msg-3', 'msg-4', 'msg-5', 'msg-6', 'msg-7', 'msg-8', 'msg-9'],
},
'msg-2': {
id: 'msg-2',
message: {
id: 'msg-2',
author: { role: 'assistant' },
create_time: 1714585032.150442,
content: { content_type: 'text', parts: ['GPT-4 response'] },
metadata: { model_slug: 'gpt-4' },
},
parent: 'msg-1',
children: [],
},
'msg-3': {
id: 'msg-3',
message: {
id: 'msg-3',
author: { role: 'assistant' },
create_time: 1714585033.150442,
content: { content_type: 'text', parts: ['GPT-4o response'] },
metadata: { model_slug: 'gpt-4o' },
},
parent: 'msg-1',
children: [],
},
'msg-4': {
id: 'msg-4',
message: {
id: 'msg-4',
author: { role: 'assistant' },
create_time: 1714585034.150442,
content: { content_type: 'text', parts: ['GPT-4o-mini response'] },
metadata: { model_slug: 'gpt-4o-mini' },
},
parent: 'msg-1',
children: [],
},
'msg-5': {
id: 'msg-5',
message: {
id: 'msg-5',
author: { role: 'assistant' },
create_time: 1714585035.150442,
content: { content_type: 'text', parts: ['GPT-3.5-turbo response'] },
metadata: { model_slug: 'gpt-3.5-turbo' },
},
parent: 'msg-1',
children: [],
},
'msg-6': {
id: 'msg-6',
message: {
id: 'msg-6',
author: { role: 'assistant' },
create_time: 1714585036.150442,
content: { content_type: 'text', parts: ['GPT-4-turbo response'] },
metadata: { model_slug: 'gpt-4-turbo' },
},
parent: 'msg-1',
children: [],
},
'msg-7': {
id: 'msg-7',
message: {
id: 'msg-7',
author: { role: 'assistant' },
create_time: 1714585037.150442,
content: { content_type: 'text', parts: ['GPT-4-1106-preview response'] },
metadata: { model_slug: 'gpt-4-1106-preview' },
},
parent: 'msg-1',
children: [],
},
'msg-8': {
id: 'msg-8',
message: {
id: 'msg-8',
author: { role: 'assistant' },
create_time: 1714585038.150442,
content: { content_type: 'text', parts: ['Claude response'] },
metadata: { model_slug: 'claude-3-opus' },
},
parent: 'msg-1',
children: [],
},
'msg-9': {
id: 'msg-9',
message: {
id: 'msg-9',
author: { role: 'assistant' },
create_time: 1714585039.150442,
content: { content_type: 'text', parts: ['No model slug response'] },
metadata: {},
},
parent: 'msg-1',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(testData);
await importer(testData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
// Test various GPT model slug formats
const gpt4 = savedMessages.find((msg) => msg.text === 'GPT-4 response');
expect(gpt4.sender).toBe('GPT-4');
expect(gpt4.model).toBe('gpt-4');
const gpt4o = savedMessages.find((msg) => msg.text === 'GPT-4o response');
expect(gpt4o.sender).toBe('GPT-4o');
expect(gpt4o.model).toBe('gpt-4o');
const gpt4oMini = savedMessages.find((msg) => msg.text === 'GPT-4o-mini response');
expect(gpt4oMini.sender).toBe('GPT-4o-mini');
expect(gpt4oMini.model).toBe('gpt-4o-mini');
const gpt35Turbo = savedMessages.find((msg) => msg.text === 'GPT-3.5-turbo response');
expect(gpt35Turbo.sender).toBe('GPT-3.5-turbo');
expect(gpt35Turbo.model).toBe('gpt-3.5-turbo');
const gpt4Turbo = savedMessages.find((msg) => msg.text === 'GPT-4-turbo response');
expect(gpt4Turbo.sender).toBe('GPT-4-turbo');
expect(gpt4Turbo.model).toBe('gpt-4-turbo');
const gpt4Preview = savedMessages.find((msg) => msg.text === 'GPT-4-1106-preview response');
expect(gpt4Preview.sender).toBe('GPT-4-1106-preview');
expect(gpt4Preview.model).toBe('gpt-4-1106-preview');
// Test non-GPT model (should use the model slug as sender)
const claude = savedMessages.find((msg) => msg.text === 'Claude response');
expect(claude.sender).toBe('claude-3-opus');
expect(claude.model).toBe('claude-3-opus');
// Test missing model slug (should default to openAISettings.model.default)
const noModel = savedMessages.find((msg) => msg.text === 'No model slug response');
// When no model slug is provided, it defaults to gpt-4o-mini which gets formatted to GPT-4o-mini
expect(noModel.sender).toBe('GPT-4o-mini');
expect(noModel.model).toBe(openAISettings.model.default);
// Verify user message is unaffected
const userMsg = savedMessages.find((msg) => msg.text === 'Test message');
expect(userMsg.sender).toBe('user');
expect(userMsg.isCreatedByUser).toBe(true);
});
});
describe('importLibreChatConvo', () => {

View File

@@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getAppConfig } = require('~/server/services/Config');
const {
@@ -121,9 +122,18 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
);
}
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
user = {
provider: 'ldap',
ldapId,
@@ -133,7 +143,6 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
name: fullName,
role,
};
const appConfig = await getAppConfig({ role: user?.role });
const balanceConfig = getBalanceConfig(appConfig);
const userId = await createUser(user, balanceConfig);
user._id = userId;

View File

@@ -15,6 +15,7 @@ const {
getBalanceConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
@@ -339,6 +340,19 @@ async function setupOpenId() {
async (tokenset, done) => {
try {
const claims = tokenset.claims();
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const result = await findOpenIDUser({
openidId: claims.sub,
email: claims.email,
@@ -353,10 +367,7 @@ async function setupOpenId() {
message: ErrorTypes.AUTH_FAILED,
});
}
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
@@ -398,7 +409,6 @@ async function setupOpenId() {
);
}
const appConfig = await getAppConfig();
if (!user) {
user = {
provider: 'openid',

View File

@@ -7,6 +7,7 @@ const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const paths = require('~/config/paths');
@@ -192,16 +193,25 @@ async function setupSaml() {
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
logger.debug('[samlStrategy] SAML profile:', profile);
const userEmail = getEmail(profile) || '';
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
let user = await findUser({ samlId: profile.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
);
if (!user) {
const email = getEmail(profile) || '';
user = await findUser({ email });
user = await findUser({ email: userEmail });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile.email}`,
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`,
);
}
@@ -220,13 +230,12 @@ async function setupSaml() {
getUserName(profile) || getGivenName(profile) || getEmail(profile),
);
const appConfig = await getAppConfig();
if (!user) {
user = {
provider: 'saml',
samlId: profile.nameID,
username,
email: getEmail(profile) || '',
email: userEmail,
emailVerified: true,
name: fullName,
};

View File

@@ -2,6 +2,7 @@ const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getAppConfig } = require('~/server/services/Config');
const { findUser } = require('~/models');
@@ -14,8 +15,18 @@ const socialLogin =
});
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return cb(error);
}
const existingUser = await findUser({ email: email.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl, appConfig);
@@ -30,20 +41,29 @@ const socialLogin =
return cb(error);
}
if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await createSocialUser({
email,
avatarUrl,
provider,
providerKey: `${provider}Id`,
providerId: id,
username,
name,
emailVerified,
appConfig,
});
return cb(null, newUser);
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (!ALLOW_SOCIAL_REGISTRATION) {
logger.error(
`[${provider}Login] Registration blocked - social registration is disabled [Email: ${email}]`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Social registration is disabled';
return cb(error);
}
const newUser = await createSocialUser({
email,
avatarUrl,
provider,
providerKey: `${provider}Id`,
providerId: id,
username,
name,
emailVerified,
appConfig,
});
return cb(null, newUser);
} catch (err) {
logger.error(`[${provider}Login]`, err);
return cb(err);

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,3 +1,4 @@
/** v0.8.0-rc4 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -28,7 +29,8 @@ module.exports = {
'jest-file-loader',
'^test/(.*)$': '<rootDir>/test/$1',
'^~/(.*)$': '<rootDir>/src/$1',
'^librechat-data-provider/react-query$': '<rootDir>/../node_modules/librechat-data-provider/src/react-query',
'^librechat-data-provider/react-query$':
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
},
restoreMocks: true,
testResultsProcessor: 'jest-junit',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc3",
"version": "v0.8.0-rc4",
"description": "",
"type": "module",
"scripts": {
@@ -148,8 +148,8 @@
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.3.4",
"vite-plugin-compression2": "^1.3.3",
"vite": "^6.3.6",
"vite-plugin-compression2": "^2.2.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2"
}

View File

@@ -1,19 +1,19 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
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 {
useMCPServerManager,
useSearchApiKeyForm,
useGetAgentsConfig,
useCodeApiKeyForm,
useGetMCPTools,
useToolToggle,
} from '~/hooks';
import { getTimestampedValue, setTimestamp } from '~/utils/timestamps';
import { ephemeralAgentByConvoId } from '~/store';
interface BadgeRowContextType {
conversationId?: string | null;
mcpServerNames?: string[] | null;
agentsConfig?: TAgentsEndpoint | null;
webSearch: ReturnType<typeof useToolToggle>;
artifacts: ReturnType<typeof useToolToggle>;
@@ -21,6 +21,7 @@ interface BadgeRowContextType {
codeInterpreter: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
mcpServerManager: ReturnType<typeof useMCPServerManager>;
}
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
@@ -46,7 +47,6 @@ export default function BadgeRowProvider({
}: BadgeRowProviderProps) {
const lastKeyRef = useRef<string>('');
const hasInitializedRef = useRef(false);
const { mcpToolDetails } = useGetMCPTools();
const { agentsConfig } = useGetAgentsConfig();
const key = conversationId ?? Constants.NEW_CONVO;
@@ -62,16 +62,15 @@ export default function BadgeRowProvider({
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 codeToggleValue = getTimestampedValue(codeToggleKey);
const webSearchToggleValue = getTimestampedValue(webSearchToggleKey);
const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey);
const artifactsToggleValue = getTimestampedValue(artifactsToggleKey);
const initialValues: Record<string, any> = {};
@@ -107,15 +106,37 @@ export default function BadgeRowProvider({
}
}
// 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 || {}),
/**
* 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
*/
const finalValues = {
[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,
};
setEphemeralAgent((prev) => ({
...(prev || {}),
...finalValues,
}));
Object.entries(finalValues).forEach(([toolKey, value]) => {
if (value !== false) {
let storageKey = artifactsToggleKey;
if (toolKey === Tools.execute_code) {
storageKey = codeToggleKey;
} else if (toolKey === Tools.web_search) {
storageKey = webSearchToggleKey;
} else if (toolKey === Tools.file_search) {
storageKey = fileSearchToggleKey;
}
// Store the value and set timestamp for existing values
localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
}
});
}
}, [key, isSubmitting, setEphemeralAgent]);
@@ -165,20 +186,18 @@ export default function BadgeRowProvider({
isAuthenticated: true,
});
const mcpServerNames = useMemo(() => {
return (mcpToolDetails ?? []).map((tool) => tool.name);
}, [mcpToolDetails]);
const mcpServerManager = useMCPServerManager({ conversationId });
const value: BadgeRowContextType = {
webSearch,
artifacts,
fileSearch,
agentsConfig,
mcpServerNames,
conversationId,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
};
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;

View File

@@ -642,10 +642,3 @@ declare global {
google_tag_manager?: unknown;
}
}
export type UIResource = {
uri: string;
mimeType: string;
text: string;
[key: string]: unknown;
};

View File

@@ -13,7 +13,7 @@ interface AgentGridProps {
category: string; // Currently selected category
searchQuery: string; // Current search query
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
}
/**
@@ -23,7 +23,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
category,
searchQuery,
onSelectAgent,
scrollElement,
scrollElementRef,
}) => {
const localize = useLocalize();
@@ -87,7 +87,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Set up infinite scroll
const { setScrollElement } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
isLoading: isFetching || isFetchingNextPage,
fetchNextPage: () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
@@ -99,10 +99,11 @@ const AgentGrid: React.FC<AgentGridProps> = ({
// Connect the scroll element when it's provided
useEffect(() => {
const scrollElement = scrollElementRef?.current;
if (scrollElement) {
setScrollElement(scrollElement);
}
}, [scrollElement, setScrollElement]);
}, [scrollElementRef, setScrollElement]);
/**
* Get category display name from API data or use fallback

View File

@@ -11,7 +11,6 @@ import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus';
import CategoryTabs from './CategoryTabs';
@@ -198,21 +197,21 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
*/
const handleSearch = (query: string) => {
const newParams = new URLSearchParams(searchParams);
const currentCategory = displayCategory;
if (query.trim()) {
newParams.set('q', query.trim());
// Switch to "all" category when starting a new search
navigate(`/agents/all?${newParams.toString()}`);
} else {
newParams.delete('q');
// Preserve current category when clearing search
const currentCategory = displayCategory;
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
}
}
// Always preserve current category when searching or clearing search
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
}
};
@@ -272,100 +271,176 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<MarketplaceProvider>
<SidePanelProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
>
<main className="flex h-full flex-col overflow-hidden" role="main">
{/* Scrollable container */}
<div
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
<div className="mx-1 flex items-center gap-2">
{!navVisible ? (
<>
<OpenSidebar setNavVisible={setNavVisible} />
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
</Button>
}
/>
</>
) : (
// Invisible placeholder to maintain height
<div className="h-10 w-10" />
)}
</div>
</div>
)}
{/* Hero Section - scrolls away */}
{!isSmallScreen && (
<div className="container mx-auto max-w-4xl">
<div className={cn('mb-8 text-center', 'mt-12')}>
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
{localize('com_agents_marketplace_subtitle')}
</p>
</div>
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div
className={cn(
'sticky z-10 bg-presentation pb-4',
isSmallScreen ? 'top-0' : 'top-14',
)}
>
<div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={displayCategory}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
<SidePanelProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
>
<main className="flex h-full flex-col overflow-hidden" role="main">
{/* Scrollable container */}
<div
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
<div className="mx-1 flex items-center gap-2">
{!navVisible ? (
<>
<OpenSidebar setNavVisible={setNavVisible} />
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
</Button>
}
/>
</>
) : (
// Invisible placeholder to maintain height
<div className="h-10 w-10" />
)}
</div>
</div>
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Two-pane animated container wrapping category header + grid */}
<div className="relative overflow-hidden">
{/* Current content pane */}
)}
{/* Hero Section - scrolls away */}
{!isSmallScreen && (
<div className="container mx-auto max-w-4xl">
<div className={cn('mb-8 text-center', 'mt-12')}>
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
{localize('com_agents_marketplace_subtitle')}
</p>
</div>
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div
className={cn(
'sticky z-10 bg-presentation pb-4',
isSmallScreen ? 'top-0' : 'top-14',
)}
>
<div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={displayCategory}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
</div>
</div>
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Two-pane animated container wrapping category header + grid */}
<div className="relative overflow-hidden">
{/* Current content pane */}
<div
className={cn(
isTransitioning &&
(animationDirection === 'right'
? 'motion-safe:animate-slide-out-left'
: 'motion-safe:animate-slide-out-right'),
)}
key={`pane-current-${displayCategory}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (displayCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (displayCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory,
);
if (categoryData) {
return {
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
key={`grid-${displayCategory}`}
category={displayCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
/>
</div>
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div
className={cn(
isTransitioning &&
(animationDirection === 'right'
? 'motion-safe:animate-slide-out-left'
: 'motion-safe:animate-slide-out-right'),
'absolute inset-0',
animationDirection === 'right'
? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-in-left',
)}
key={`pane-current-${displayCategory}`}
key={`pane-next-${nextCategory}-${animationDirection}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
@@ -373,13 +448,13 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{(() => {
// Get category data for display
const getCategoryData = () => {
if (displayCategory === 'promoted') {
if (nextCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (displayCategory === 'all') {
if (nextCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
@@ -388,7 +463,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory,
(cat) => cat.value === nextCategory,
);
if (categoryData) {
return {
@@ -396,7 +471,9 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
? localize(
categoryData.description as Parameters<typeof localize>[0],
)
: categoryData.description || '',
};
}
@@ -404,8 +481,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Fallback for unknown categories
return {
name:
displayCategory.charAt(0).toUpperCase() +
displayCategory.slice(1),
(nextCategory || '').charAt(0).toUpperCase() +
(nextCategory || '').slice(1),
description: '',
};
};
@@ -426,113 +503,30 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Agent grid */}
<AgentGrid
key={`grid-${displayCategory}`}
category={displayCategory}
key={`grid-${nextCategory}`}
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
scrollElementRef={scrollContainerRef}
/>
</div>
)}
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div
className={cn(
'absolute inset-0',
animationDirection === 'right'
? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-in-left',
)}
key={`pane-next-${nextCategory}-${animationDirection}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (nextCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (nextCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === nextCategory,
);
if (categoryData) {
return {
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(
categoryData.description as Parameters<
typeof localize
>[0],
)
: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
(nextCategory || '').charAt(0).toUpperCase() +
(nextCategory || '').slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
key={`grid-${nextCategory}`}
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current}
/>
</div>
)}
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div>
</main>
</SidePanelGroup>
</SidePanelProvider>
</MarketplaceProvider>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div>
</main>
</SidePanelGroup>
</SidePanelProvider>
</div>
);
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import type t from 'librechat-data-provider';
@@ -81,6 +82,115 @@ import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
// Helper to create mock API response
const createMockResponse = (
agentIds: string[],
hasMore: boolean,
afterCursor?: string,
): t.AgentListResponse => ({
object: 'list',
data: agentIds.map(
(id) =>
({
id,
name: `Agent ${id}`,
description: `Description for ${id}`,
created_at: Date.now(),
model: 'gpt-4',
tools: [],
instructions: '',
avatar: null,
provider: 'openai',
model_parameters: {
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
maxContextTokens: 2000,
max_context_tokens: 2000,
max_output_tokens: 2000,
},
}) as t.Agent,
),
first_id: agentIds[0] || '',
last_id: agentIds[agentIds.length - 1] || '',
has_more: hasMore,
after: afterCursor,
});
// Helper to setup mock viewport
const setupViewport = (scrollHeight: number, clientHeight: number) => {
const listeners: { [key: string]: EventListener[] } = {};
return {
scrollHeight,
clientHeight,
scrollTop: 0,
addEventListener: jest.fn((event: string, listener: EventListener) => {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(listener);
}),
removeEventListener: jest.fn((event: string, listener: EventListener) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter((l) => l !== listener);
}
}),
dispatchEvent: jest.fn((event: Event) => {
const eventListeners = listeners[event.type];
if (eventListeners) {
eventListeners.forEach((listener) => listener(event));
}
return true;
}),
} as unknown as HTMLElement;
};
// Helper to create mock infinite query return value
const createMockInfiniteQuery = (
pages: t.AgentListResponse[],
options?: {
isLoading?: boolean;
hasNextPage?: boolean;
fetchNextPage?: jest.Mock;
isFetchingNextPage?: boolean;
},
) =>
({
data: {
pages,
pageParams: pages.map((_, i) => (i === 0 ? undefined : `cursor-${i * 6}`)),
},
isLoading: options?.isLoading ?? false,
error: null,
isFetching: false,
hasNextPage: options?.hasNextPage ?? pages[pages.length - 1]?.has_more ?? false,
isFetchingNextPage: options?.isFetchingNextPage ?? false,
fetchNextPage: options?.fetchNextPage ?? jest.fn(),
refetch: jest.fn(),
// Add missing required properties for UseInfiniteQueryResult
isError: false,
isLoadingError: false,
isRefetchError: false,
isSuccess: true,
status: 'success' as const,
dataUpdatedAt: Date.now(),
errorUpdateCount: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
fetchStatus: 'idle' as const,
isFetched: true,
isFetchedAfterMount: true,
isInitialLoading: false,
isPaused: false,
isPlaceholderData: false,
isPending: false,
isRefetching: false,
isStale: false,
remove: jest.fn(),
}) as any;
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
const mockOnSelectAgent = jest.fn();
@@ -343,6 +453,15 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
});
describe('Infinite Scroll Functionality', () => {
beforeEach(() => {
// Silence console.log in tests
jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should show loading indicator when fetching next page', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
@@ -396,5 +515,358 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
});
describe('Auto-fetch to fill viewport', () => {
it('should NOT auto-fetch when viewport is filled (5 agents, has_more=false)', async () => {
const mockResponse = createMockResponse(['1', '2', '3', '4', '5'], false);
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
createMockInfiniteQuery([mockResponse], { fetchNextPage }),
);
const scrollElement = setupViewport(500, 1000); // Content smaller than viewport
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial render
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(5);
});
// Wait to ensure no auto-fetch happens
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// fetchNextPage should NOT be called since has_more is false
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('should auto-fetch when viewport not filled (7 agents, big viewport)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const secondPage = createMockResponse(['7'], false);
let currentPages = [firstPage];
const fetchNextPage = jest.fn();
// Mock that updates pages when fetchNextPage is called
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchNextPage();
currentPages = [firstPage, secondPage];
return Promise.resolve();
}),
hasNextPage: true,
}),
);
const scrollElement = setupViewport(400, 1200); // Large viewport (content < viewport)
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Wait for ResizeObserver and auto-fetch to trigger
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
// Auto-fetch should have been triggered (multiple times due to reliability checks)
expect(fetchNextPage).toHaveBeenCalled();
expect(fetchNextPage.mock.calls.length).toBeGreaterThanOrEqual(1);
// Update mock data and re-render
currentPages = [firstPage, secondPage];
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should now show all 7 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(7);
});
});
it('should NOT auto-fetch when viewport is filled (7 agents, small viewport)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
createMockInfiniteQuery([firstPage], { fetchNextPage, hasNextPage: true }),
);
const scrollElement = setupViewport(1200, 600); // Small viewport, content fills it
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Wait to ensure no auto-fetch happens
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// Should NOT auto-fetch since viewport is filled
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('should auto-fetch once to fill viewport then stop (20 agents)', async () => {
const allPages = [
createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'),
createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12'),
createMockResponse(['13', '14', '15', '16', '17', '18'], true, 'cursor-18'),
createMockResponse(['19', '20'], false),
];
let currentPages = [allPages[0]];
let fetchCount = 0;
const fetchNextPage = jest.fn();
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchCount++;
fetchNextPage();
if (currentPages.length < 2) {
currentPages = allPages.slice(0, 2);
}
return Promise.resolve();
}),
hasNextPage: currentPages.length < 2,
}),
);
const scrollElement = setupViewport(600, 1000); // Viewport fits ~12 agents
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Should auto-fetch to fill viewport
await waitFor(
() => {
expect(fetchNextPage).toHaveBeenCalledTimes(1);
},
{ timeout: 500 },
);
// Simulate viewport being filled after 12 agents
Object.defineProperty(scrollElement, 'scrollHeight', {
value: 1200,
writable: true,
configurable: true,
});
currentPages = allPages.slice(0, 2);
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should show 12 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
});
// Wait to ensure no additional auto-fetch
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// Should only have fetched once (to fill viewport)
expect(fetchCount).toBe(1);
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
it('should auto-fetch when viewport resizes to be taller (window resize)', async () => {
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
const secondPage = createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12');
let currentPages = [firstPage];
const fetchNextPage = jest.fn();
let resizeObserverCallback: ResizeObserverCallback | null = null;
// Mock that updates pages when fetchNextPage is called
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
createMockInfiniteQuery(currentPages, {
fetchNextPage: jest.fn().mockImplementation(() => {
fetchNextPage();
if (currentPages.length === 1) {
currentPages = [firstPage, secondPage];
}
return Promise.resolve();
}),
hasNextPage: currentPages.length === 1,
}),
);
// Mock ResizeObserver to capture the callback
const ResizeObserverMock = jest.fn().mockImplementation((callback) => {
resizeObserverCallback = callback;
return {
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
};
});
global.ResizeObserver = ResizeObserverMock as any;
// Start with a small viewport that fits the content
const scrollElement = setupViewport(800, 600);
const scrollElementRef = { current: scrollElement };
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Wait for initial 6 agents
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
});
// Verify ResizeObserver was set up
expect(ResizeObserverMock).toHaveBeenCalled();
expect(resizeObserverCallback).not.toBeNull();
// Initially no fetch should happen as viewport is filled
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(fetchNextPage).not.toHaveBeenCalled();
// Simulate window resize - make viewport taller
Object.defineProperty(scrollElement, 'clientHeight', {
value: 1200, // Now taller than content
writable: true,
configurable: true,
});
// Trigger ResizeObserver callback to simulate resize detection
act(() => {
if (resizeObserverCallback) {
resizeObserverCallback(
[
{
target: scrollElement,
contentRect: {
x: 0,
y: 0,
width: 800,
height: 1200,
top: 0,
right: 800,
bottom: 1200,
left: 0,
} as DOMRectReadOnly,
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as ResizeObserverEntry,
],
{} as ResizeObserver,
);
}
});
// Should trigger auto-fetch due to viewport now being larger than content
await waitFor(
() => {
expect(fetchNextPage).toHaveBeenCalledTimes(1);
},
{ timeout: 500 },
);
// Update the component with new data
rerender(
<Wrapper>
<AgentGrid
category="all"
searchQuery=""
onSelectAgent={mockOnSelectAgent}
scrollElementRef={scrollElementRef}
/>
</Wrapper>,
);
// Should now show 12 agents after fetching
await waitFor(() => {
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
});
});
});
});
});

View File

@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
// Performance check: rendering should be fast
const renderTime = endTime - startTime;
expect(renderTime).toBeLessThan(650);
expect(renderTime).toBeLessThan(720);
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo, useCallback, useState, useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { Spinner } from '@librechat/client';
@@ -13,6 +13,7 @@ import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
import CostBar from './CostBar';
import Landing from './Landing';
import Header from './Header';
import Footer from './Footer';
@@ -29,7 +30,13 @@ function LoadingSpinner() {
);
}
function ChatView({ index = 0 }: { index?: number }) {
function ChatView({
index = 0,
modelCosts,
}: {
index?: number;
modelCosts?: { modelCostTable: Record<string, { prompt: number; completion: number }> };
}) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
@@ -37,6 +44,9 @@ function ChatView({ index = 0 }: { index?: number }) {
const fileMap = useFileMapContext();
const [showCostBar, setShowCostBar] = useState(false);
const lastScrollY = useRef(0);
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
select: useCallback(
(data: TMessage[]) => {
@@ -54,6 +64,58 @@ function ChatView({ index = 0 }: { index?: number }) {
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
const checkIfAtBottom = useCallback(
(container: HTMLElement) => {
const currentScrollY = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollY - clientHeight;
const isAtBottom = distanceFromBottom < 10;
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
setShowCostBar(isAtBottom && !isStreaming);
lastScrollY.current = currentScrollY;
},
[chatHelpers.isSubmitting, addedChatHelpers.isSubmitting],
);
useEffect(() => {
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement;
checkIfAtBottom(target);
};
const findAndAttachScrollListener = () => {
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
if (messagesContainer) {
checkIfAtBottom(messagesContainer as HTMLElement);
messagesContainer.addEventListener('scroll', handleScroll, { passive: true });
return () => {
messagesContainer.removeEventListener('scroll', handleScroll);
};
}
setTimeout(findAndAttachScrollListener, 100);
};
const cleanup = findAndAttachScrollListener();
return cleanup;
}, [messagesTree, checkIfAtBottom]);
useEffect(() => {
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
if (isStreaming) {
setShowCostBar(false);
} else {
const messagesContainer = document.querySelector('[class*="scrollbar-gutter-stable"]');
if (messagesContainer) {
checkIfAtBottom(messagesContainer as HTMLElement);
}
}
}, [chatHelpers.isSubmitting, addedChatHelpers.isSubmitting, checkIfAtBottom]);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
@@ -69,7 +131,22 @@ function ChatView({ index = 0 }: { index?: number }) {
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
const isStreaming = chatHelpers.isSubmitting || addedChatHelpers.isSubmitting;
content = (
<MessagesView
messagesTree={messagesTree}
costBar={
!isLandingPage &&
modelCosts && (
<CostBar
messagesTree={messagesTree}
modelCosts={modelCosts}
showCostBar={showCostBar && !isStreaming}
/>
)
}
/>
);
} else {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { ArrowIcon } from '@librechat/client';
import { TModelCosts, TMessage } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
interface CostBarProps {
messagesTree: TMessage[];
modelCosts: TModelCosts;
showCostBar: boolean;
}
export default function CostBar({ messagesTree, modelCosts, showCostBar }: CostBarProps) {
const localize = useLocalize();
const showCostTracking = useRecoilValue(store.showCostTracking);
const conversationCosts = useMemo(() => {
if (!modelCosts?.modelCostTable || !messagesTree) {
return null;
}
let totalPromptTokens = 0;
let totalCompletionTokens = 0;
let totalPromptUSD = 0;
let totalCompletionUSD = 0;
const flattenMessages = (messages: TMessage[]) => {
const flattened: TMessage[] = [];
messages.forEach((message: TMessage) => {
flattened.push(message);
if (message.children && message.children.length > 0) {
flattened.push(...flattenMessages(message.children));
}
});
return flattened;
};
const allMessages = flattenMessages(messagesTree);
allMessages.forEach((message) => {
if (!message.tokenCount) {
return null;
}
const modelToUse = message.isCreatedByUser ? message.targetModel : message.model;
const modelPricing = modelCosts.modelCostTable[modelToUse];
if (message.isCreatedByUser) {
totalPromptTokens += message.tokenCount;
totalPromptUSD += (message.tokenCount / 1000000) * modelPricing.prompt;
} else {
totalCompletionTokens += message.tokenCount;
totalCompletionUSD += (message.tokenCount / 1000000) * modelPricing.completion;
}
});
const totalTokens = totalPromptTokens + totalCompletionTokens;
const totalUSD = totalPromptUSD + totalCompletionUSD;
return {
totals: {
prompt: { tokenCount: totalPromptTokens, usd: totalPromptUSD },
completion: { tokenCount: totalCompletionTokens, usd: totalCompletionUSD },
total: { tokenCount: totalTokens, usd: totalUSD },
},
};
}, [modelCosts, messagesTree]);
if (!showCostTracking || !conversationCosts || !conversationCosts.totals) {
return null;
}
return (
<div
className={cn(
'mx-auto w-full max-w-md px-4 text-xs text-muted-foreground transition-all duration-300 ease-in-out',
showCostBar ? 'opacity-100' : 'opacity-0',
)}
>
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div>
<ArrowIcon direction="up" />
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.prompt.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.prompt.usd).toFixed(6)}</div>
</div>
<div>
<div>
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.total.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.total.usd).toFixed(6)}</div>
</div>
<div>
<div>
<ArrowIcon direction="down" />
{localize('com_ui_token_abbreviation', {
0: conversationCosts.totals.completion.tokenCount,
})}
</div>
<div>${Math.abs(conversationCosts.totals.completion.usd).toFixed(6)}</div>
</div>
</div>
</div>
);
}

View File

@@ -368,7 +368,7 @@ function BadgeRow({
<CodeInterpreter />
<FileSearch />
<Artifacts />
<MCPSelect conversationId={conversationId} />
<MCPSelect />
</>
)}
{ghostBadge && (

View File

@@ -39,6 +39,7 @@ function AttachFileChat({
<AttachFileMenu
disabled={disableInputs}
conversationId={conversationId}
agentId={conversation?.agent_id}
endpointFileConfig={endpointFileConfig}
/>
);

View File

@@ -11,7 +11,13 @@ import {
SharePointIcon,
} from '@librechat/client';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
import {
useAgentToolPermissions,
useAgentCapabilities,
useGetAgentsConfig,
useFileHandling,
useLocalize,
} from '~/hooks';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
import { SharePointPickerDialog } from '~/components/SharePoint';
import { useGetStartupConfig } from '~/data-provider';
@@ -21,11 +27,17 @@ import { cn } from '~/utils';
interface AttachFileMenuProps {
conversationId: string;
agentId?: string | null;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
}
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const AttachFileMenu = ({
agentId,
disabled,
conversationId,
endpointFileConfig,
}: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
@@ -52,6 +64,8 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
* */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId);
const handleUploadClick = (isImage?: boolean) => {
if (!inputRef.current) {
return;
@@ -86,7 +100,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
});
}
if (capabilities.fileSearchEnabled) {
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
items.push({
label: localize('com_ui_upload_file_search'),
onClick: () => {
@@ -101,7 +115,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
});
}
if (capabilities.codeEnabled) {
if (capabilities.codeEnabled && codeAllowedByAgent) {
items.push({
label: localize('com_ui_upload_code_files'),
onClick: () => {
@@ -142,6 +156,8 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
setToolResource,
setEphemeralAgent,
sharePointEnabled,
codeAllowedByAgent,
fileSearchAllowedByAgent,
setIsSharePointDialogOpen,
]);

View File

@@ -2,7 +2,13 @@ import React, { useMemo } from 'react';
import { OGDialog, OGDialogTemplate } from '@librechat/client';
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
import { useLocalize, useGetAgentsConfig, useAgentCapabilities } from '~/hooks';
import {
useAgentToolPermissions,
useAgentCapabilities,
useGetAgentsConfig,
useLocalize,
} from '~/hooks';
import { useChatContext } from '~/Providers';
interface DragDropModalProps {
onOptionSelect: (option: EToolResources | undefined) => void;
@@ -26,6 +32,11 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
* Use definition for agents endpoint for ephemeral agents
* */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { conversation } = useChatContext();
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
conversation?.agent_id,
);
const options = useMemo(() => {
const _options: FileOption[] = [
{
@@ -35,14 +46,14 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
condition: files.every((file) => file.type?.startsWith('image/')),
},
];
if (capabilities.fileSearchEnabled) {
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
_options.push({
label: localize('com_ui_upload_file_search'),
value: EToolResources.file_search,
icon: <FileSearch className="icon-md" />,
});
}
if (capabilities.codeEnabled) {
if (capabilities.codeEnabled && codeAllowedByAgent) {
_options.push({
label: localize('com_ui_upload_code_files'),
value: EToolResources.execute_code,
@@ -58,7 +69,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
}
return _options;
}, [capabilities, files, localize]);
}, [capabilities, files, localize, fileSearchAllowedByAgent, codeAllowedByAgent]);
if (!isVisible) {
return null;

View File

@@ -1,62 +1,102 @@
export default function DragDropOverlay() {
return (
<div
className="bg-surface-primary/85 fixed inset-0 z-[9999] flex flex-col items-center justify-center
gap-2 text-text-primary
backdrop-blur-[4px] transition-all duration-200
ease-in-out animate-in fade-in
zoom-in-95 hover:backdrop-blur-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 132 108"
fill="none"
width="132"
height="108"
>
<g clipPath="url(#clip0_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
fill="#AFC1FF"
/>
</g>
<g clipPath="url(#clip1_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
fill="#7989FF"
/>
</g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
fill="#3C46FF"
/>
<defs>
<clipPath id="clip0_3605_64419">
<rect
width="56"
height="56"
fill="white"
transform="translate(0 26.9939) rotate(-15)"
/>
</clipPath>
<clipPath id="clip1_3605_64419">
<rect
width="64"
height="64"
fill="white"
transform="translate(69.5645 0.5) rotate(15)"
/>
</clipPath>
</defs>
</svg>
<h3>Add anything</h3>
<h4>Drop any file here to add it to the conversation</h4>
</div>
);
import { memo } from 'react';
import { useLocalize } from '~/hooks';
interface DragDropOverlayProps {
isActive: boolean;
}
const DragDropOverlay = memo(({ isActive }: DragDropOverlayProps) => {
const localize = useLocalize();
return (
<>
{/** Modal backdrop overlay */}
<div
className={`fixed inset-0 z-[9998] transition-opacity duration-200 ease-in-out ${
isActive
? 'pointer-events-auto visible opacity-100'
: 'pointer-events-none invisible opacity-0'
} `}
style={{
/** Semi-transparent black overlay that works in both themes */
backgroundColor: 'rgba(0, 0, 0, 0.4)',
willChange: 'opacity',
}}
/>
{/** Main content overlay */}
<div
className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-2 text-text-primary transition-all duration-200 ease-in-out ${
isActive
? 'pointer-events-auto visible opacity-100'
: 'pointer-events-none invisible opacity-0'
} `}
style={{
transform: isActive ? 'scale(1)' : 'scale(0.95)',
/** Use will-change to hint browser about upcoming changes */
willChange: 'opacity, transform',
}}
>
{/** Content area with subtle background */}
<div className="bg-surface-primary/95 flex flex-col items-center rounded-lg p-8 shadow-xl">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 132 108"
fill="none"
width="132"
height="108"
style={{
transform: isActive ? 'translateY(0)' : 'translateY(-10px)',
transition: 'transform 0.2s ease-in-out',
}}
>
<g clipPath="url(#clip0_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
fill="#AFC1FF"
/>
</g>
<g clipPath="url(#clip1_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
fill="#7989FF"
/>
</g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
fill="#3C46FF"
/>
<defs>
<clipPath id="clip0_3605_64419">
<rect
width="56"
height="56"
fill="white"
transform="translate(0 26.9939) rotate(-15)"
/>
</clipPath>
<clipPath id="clip1_3605_64419">
<rect
width="64"
height="64"
fill="white"
transform="translate(69.5645 0.5) rotate(15)"
/>
</clipPath>
</defs>
</svg>
<h3 className="mt-4 text-lg font-semibold">{localize('com_ui_upload_files')}</h3>
<h4 className="text-sm text-text-secondary">{localize('com_ui_drag_drop')}</h4>
</div>
</div>
</>
);
});
DragDropOverlay.displayName = 'DragDropOverlay';
export default DragDropOverlay;

View File

@@ -17,7 +17,8 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper
return (
<div ref={drop} className={cn('relative flex h-full w-full', className)}>
{children}
{isActive && <DragDropOverlay />}
{/** Always render overlay to avoid mount/unmount overhead */}
<DragDropOverlay isActive={isActive} />
<DragDropModal
files={draggedFiles}
isVisible={showModal}

View File

@@ -3,22 +3,19 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers';
import { useMCPServerManager } from '~/hooks';
type MCPSelectProps = { conversationId?: string | null };
function MCPSelectContent({ conversationId }: MCPSelectProps) {
function MCPSelectContent() {
const { conversationId, mcpServerManager } = useBadgeRowContext();
const {
configuredServers,
mcpValues,
isPinned,
placeholderText,
batchToggleServers,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
localize,
} = useMCPServerManager({ conversationId });
mcpValues,
isInitializing,
placeholderText,
configuredServers,
batchToggleServers,
getConfigDialogProps,
getServerStatusIconProps,
} = mcpServerManager;
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
@@ -71,14 +68,6 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
[getServerStatusIconProps, isInitializing],
);
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
return null;
}
if (!configuredServers || configuredServers.length === 0) {
return null;
}
const configDialogProps = getConfigDialogProps();
return (
@@ -103,10 +92,15 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
);
}
function MCPSelect(props: MCPSelectProps) {
const { mcpServerNames } = useBadgeRowContext();
if ((mcpServerNames?.length ?? 0) === 0) return null;
return <MCPSelectContent {...props} />;
function MCPSelect() {
const { mcpServerManager } = useBadgeRowContext();
const { configuredServers } = mcpServerManager;
if (!configuredServers || configuredServers.length === 0) {
return null;
}
return <MCPSelectContent />;
}
export default memo(MCPSelect);

View File

@@ -4,27 +4,27 @@ import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useMCPServerManager } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { cn } from '~/utils';
interface MCPSubMenuProps {
placeholder?: string;
conversationId?: string | null;
}
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, conversationId, ...props }, ref) => {
({ placeholder, ...props }, ref) => {
const { mcpServerManager } = useBadgeRowContext();
const {
configuredServers,
mcpValues,
isPinned,
mcpValues,
setIsPinned,
isInitializing,
placeholderText,
configuredServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
} = useMCPServerManager({ conversationId });
} = mcpServerManager;
const menuStore = Ariakit.useMenuStore({
focusLoop: true,

View File

@@ -30,8 +30,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
artifacts,
fileSearch,
agentsConfig,
mcpServerNames,
conversationId,
mcpServerManager,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
@@ -287,15 +286,18 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
if (mcpServerNames && mcpServerNames.length > 0) {
const { configuredServers } = mcpServerManager;
if (configuredServers && configuredServers.length > 0) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
),
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
});
}
if (dropdownItems.length === 0) {
return null;
}
const menuTrigger = (
<TooltipAnchor
render={

View File

@@ -211,6 +211,7 @@ export default function ToolCall({
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
attachments={attachments}
/>
)}
</div>

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel';
import type { UIResource } from '~/common';
import type { TAttachment, UIResource } from 'librechat-data-provider';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return (
@@ -27,12 +28,14 @@ export default function ToolCallInfo({
domain,
function_name,
pendingAuth,
attachments,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const formatText = (text: string) => {
@@ -54,25 +57,12 @@ export default function ToolCallInfo({
: localize('com_assistants_attempt_info');
}
// Extract ui_resources from the output to display them in the UI
let uiResources: UIResource[] = [];
if (output?.includes('ui_resources')) {
try {
const parsedOutput = JSON.parse(output);
const uiResourcesItem = parsedOutput.find(
(contentItem) => contentItem.metadata?.type === 'ui_resources',
);
if (uiResourcesItem?.metadata?.data) {
uiResources = uiResourcesItem.metadata.data;
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
);
}
} catch (error) {
// If JSON parsing fails, keep original output
console.error('Failed to parse output:', error);
}
}
const uiResources: UIResource[] =
attachments
?.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => {
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];
return (
<div className="w-full p-2">

View File

@@ -1,6 +1,6 @@
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from '~/common';
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
interface UIResourceCarouselProps {
uiResources: UIResource[];

View File

@@ -0,0 +1,382 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import ToolCall from '../ToolCall';
// Mock dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_completed_function: `Completed ${values?.[0]}`,
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
com_assistants_running_var: `Running ${values?.[0]}`,
com_assistants_running_action: 'Running action',
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
com_ui_cancelled: 'Cancelled',
com_ui_requires_auth: 'Requires authentication',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
}));
jest.mock('../ToolCallInfo', () => ({
__esModule: true,
default: ({ attachments, ...props }: any) => (
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
{JSON.stringify(props)}
</div>
),
}));
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
<div onClick={onClick}>{finishedText || inProgressText}</div>
),
}));
jest.mock('../Parts', () => ({
AttachmentGroup: ({ attachments }: any) => (
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
),
}));
jest.mock('~/components/ui', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span>{'ChevronDown'}</span>,
ChevronUp: () => <span>{'ChevronUp'}</span>,
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
}));
jest.mock('~/utils', () => ({
logger: {
error: jest.fn(),
},
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}));
describe('ToolCall', () => {
const mockProps = {
args: '{"test": "input"}',
name: 'testFunction',
output: 'Test output',
initialProgress: 1,
isSubmitting: false,
};
const renderWithRecoil = (component: React.ReactElement) => {
return render(<RecoilRoot>{component}</RecoilRoot>);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('attachments prop passing', () => {
it('should pass attachments to ToolCallInfo when provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBe(JSON.stringify(attachments));
});
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
});
it('should pass multiple attachments of different types', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: {
'0': { type: 'form', fields: [] },
},
},
{
type: Tools.web_search,
messageId: 'msg2',
toolCallId: 'tool2',
conversationId: 'conv2',
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
});
});
describe('attachment group rendering', () => {
it('should render AttachmentGroup when attachments are provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'chart', data: [] },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(attachmentGroup).toBeInTheDocument();
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
});
it('should not render AttachmentGroup when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
it('should not render AttachmentGroup when attachments is empty array', () => {
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
});
describe('tool call info visibility', () => {
it('should toggle tool call info when clicking header', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
// Initially closed
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
// Click to close
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
});
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
];
// Use a name with domain separator (_action_) and domain separator (---)
const propsWithDomain = {
...mockProps,
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
attachments,
};
renderWithRecoil(<ToolCall {...propsWithDomain} />);
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('{"test": "input"}');
expect(props.output).toBe('Test output');
expect(props.function_name).toBe('testFunction');
// Domain is extracted from name and --- are replaced with dots
expect(props.domain).toBe('test.domain.com');
expect(props.pendingAuth).toBe(false);
});
});
describe('authentication flow', () => {
it('should show sign-in button when auth URL is provided', () => {
const originalOpen = window.open;
window.open = jest.fn();
renderWithRecoil(
<ToolCall
{...mockProps}
initialProgress={0.5} // Less than 1 so it's not complete
auth="https://auth.example.com"
isSubmitting={true} // Should be submitting for auth to show
/>,
);
const signInButton = screen.getByText('Sign in to auth.example.com');
expect(signInButton).toBeInTheDocument();
fireEvent.click(signInButton);
expect(window.open).toHaveBeenCalledWith(
'https://auth.example.com',
'_blank',
'noopener,noreferrer',
);
window.open = originalOpen;
});
it('should pass pendingAuth as true when auth is pending', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com" // Need auth URL to extract domain
initialProgress={0.5} // Less than 1
isSubmitting={true} // Still submitting
/>,
);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.pendingAuth).toBe(true);
});
it('should not show auth section when cancelled', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
it('should not show auth section when progress is complete', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('');
});
it('should handle null output', () => {
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.output).toBeNull();
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.domain).toBe('');
});
it('should handle complex nested attachments', () => {
const complexAttachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': {
type: 'nested',
data: {
deep: {
value: 'test',
array: [1, 2, 3],
object: { key: 'value' },
},
},
},
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
});
});
});

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ToolCallInfo from '../ToolCallInfo';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel';
import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
// Mock the dependencies
jest.mock('~/hooks', () => ({
@@ -46,24 +48,25 @@ describe('ToolCallInfo', () => {
jest.clearAllMocks();
});
describe('ui_resources extraction', () => {
it('should extract single ui_resource from output', () => {
describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
};
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
@@ -81,29 +84,33 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should extract multiple ui_resources from output', () => {
const uiResources = [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
it('should render carousel for multiple ui_resources from attachments', () => {
// To test multiple resources, we can use a single attachment with multiple resources
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
},
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources,
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}),
expect.any(Object),
);
@@ -112,34 +119,38 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should filter out ui_resources from displayed output', () => {
const regularContent = [
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
it('should handle attachments with normal output', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
},
];
const output = JSON.stringify([
...regularContent,
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
]);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
// Check that the displayed output doesn't contain ui_resources
// Check that the output is displayed normally
const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2');
expect(outputCode).not.toContain('ui_resources');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
});
it('should handle output without ui_resources', () => {
it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
@@ -148,66 +159,56 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle malformed ui_resources gracefully', () => {
const output = JSON.stringify([
{
metadata: 'ui_resources', // metadata should be an object, not a string
text: 'some text content',
},
]);
it('should handle empty attachments array', () => {
const attachments: TAttachment[] = [];
// Component should not throw error and should render without UI resources
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render the component without crashing
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle ui_resources as plain text without breaking', () => {
const outputWithTextOnly =
'This output contains ui_resources as plain text but not as a proper structure';
it('should handle attachments with non-ui_resources type', () => {
const attachments: TAttachment[] = [
{
type: Tools.web_search as any,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.web_search]: {
organic: [],
},
},
];
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render normally without errors
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
expect(screen.getByText('Result')).toBeInTheDocument();
// The output text should be displayed in a code block
const codeBlocks = screen.getAllByText((content, element) => {
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
});
expect(codeBlocks.length).toBeGreaterThan(0);
// Should not render UI resources components
// Should not render UI resources components for non-ui_resources attachments
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist', () => {
const output = JSON.stringify([
it('should render UI Resources heading when ui_resources exist in attachments', () => {
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
it('should not render UI Resources heading when no ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
it('should not render UI Resources heading when no ui_resources in attachments', () => {
render(<ToolCallInfo {...mockProps} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
@@ -218,16 +219,18 @@ describe('ToolCallInfo', () => {
data: { fields: [{ name: 'test', type: 'text' }] },
};
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
@@ -244,16 +247,18 @@ describe('ToolCallInfo', () => {
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
@@ -270,4 +275,55 @@ describe('ToolCallInfo', () => {
consoleSpy.mockRestore();
});
});
describe('backward compatibility', () => {
it('should handle output with ui_resources for backward compatibility', () => {
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Since we now use attachments, ui_resources in output should be ignored
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should prioritize attachments over output ui_resources', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
},
];
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'output', data: 'From output' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);
// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
);
});
});
});

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { useMessageProcess } from '~/hooks';
import type { TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender';
// eslint-disable-next-line import/no-cycle
@@ -28,7 +29,7 @@ const MessageContainer = React.memo(
},
);
export default function Message(props: TMessageProps) {
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
const {
showSibling,
conversation,
@@ -37,7 +38,7 @@ export default function Message(props: TMessageProps) {
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
const { message, currentEditId, setCurrentEditId, costs } = props;
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
if (!message || typeof message !== 'object') {
@@ -62,6 +63,7 @@ export default function Message(props: TMessageProps) {
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
costs={costs}
/>
<MessageRender
{...props}
@@ -69,12 +71,13 @@ export default function Message(props: TMessageProps) {
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
costs={costs}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<MessageRender {...props} />
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} costs={costs} />
</div>
)}
</MessageContainer>
@@ -85,6 +88,7 @@ export default function Message(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
@@ -12,10 +12,17 @@ import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
export default function Message(props: TMessageProps & { costs?: TConversationCosts }) {
const localize = useLocalize();
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
const {
message,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
setCurrentEditId,
costs,
} = props;
const { attachments, searchResults } = useAttachments({
messageId: message?.messageId,
attachments: message?.attachments,
@@ -164,6 +171,7 @@ export default function Message(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import MultiMessage from './MultiMessage';
@@ -10,8 +10,12 @@ import store from '~/store';
export default function MessagesView({
messagesTree: _messagesTree,
costBar,
costs,
}: {
messagesTree?: TMessage[] | null;
costBar?: React.ReactNode;
costs?: TConversationCosts;
}) {
const localize = useLocalize();
const fontSize = useRecoilValue(store.fontSize);
@@ -44,7 +48,7 @@ export default function MessagesView({
width: '100%',
}}
>
<div className="flex flex-col pb-9 dark:bg-transparent">
<div className="flex flex-col dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(
@@ -63,18 +67,25 @@ export default function MessagesView({
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
costs={costs}
/>
</div>
</>
)}
<div
id="messages-end"
className="group h-0 w-full flex-shrink-0"
className="group h-1 w-full flex-shrink-0 pb-7"
ref={messagesEndRef}
/>
</div>
</div>
{costBar && (
<div className="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2">
{costBar}
</div>
)}
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{

View File

@@ -1,7 +1,7 @@
import { useRecoilState } from 'recoil';
import { useEffect, useCallback } from 'react';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TMessage, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageContent from '~/components/Messages/MessageContent';
import MessageParts from './MessageParts';
@@ -14,7 +14,8 @@ export default function MultiMessage({
messagesTree,
currentEditId,
setCurrentEditId,
}: TMessageProps) {
costs,
}: TMessageProps & { costs?: TConversationCosts }) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = useCallback(
@@ -55,6 +56,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
} else if (message.content) {
@@ -67,6 +69,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
}
@@ -80,6 +83,7 @@ export default function MultiMessage({
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
costs={costs}
/>
);
}

View File

@@ -1,16 +1,17 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider';
import { ArrowIcon } from '@librechat/client';
import { type TMessage, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useMessageActions, useLocalize } from '~/hooks';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
@@ -19,6 +20,7 @@ type MessageRenderProps = {
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
costs?: TConversationCosts;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@@ -35,7 +37,9 @@ const MessageRender = memo(
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
costs,
}: MessageRenderProps) => {
const localize = useLocalize();
const {
ask,
edit,
@@ -60,6 +64,18 @@ const MessageRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const showCostTracking = useRecoilValue(store.showCostTracking);
const perMessageCost = useMemo(() => {
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
return null;
}
const entry = costs.perMessage.find((p) => p.messageId === msg.messageId);
if (!entry) {
return null;
}
return entry;
}, [showCostTracking, costs, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const hasNoChildren = !(msg?.children?.length ?? 0);
@@ -157,7 +173,26 @@ const MessageRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span>
{perMessageCost.tokenType === 'prompt' ? (
<ArrowIcon direction="up" className="inline" />
) : (
<ArrowIcon direction="down" className="inline" />
)}
{localize('com_ui_token_abbreviation', {
0: perMessageCost.tokenCount,
})}
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View File

@@ -1,13 +1,14 @@
import { useRecoilValue } from 'recoil';
import { ArrowIcon } from '@librechat/client';
import { useCallback, useMemo, memo } from 'react';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts, TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import { useAttachments, useMessageActions, useLocalize } from '~/hooks';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, logger } from '~/utils';
import store from '~/store';
@@ -17,6 +18,7 @@ type ContentRenderProps = {
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
costs?: TConversationCosts;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@@ -33,7 +35,9 @@ const ContentRender = memo(
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
costs,
}: ContentRenderProps) => {
const localize = useLocalize();
const { attachments, searchResults } = useAttachments({
messageId: msg?.messageId,
attachments: msg?.attachments,
@@ -62,6 +66,14 @@ const ContentRender = memo(
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const showCostTracking = useRecoilValue(store.showCostTracking);
const perMessageCost = useMemo(() => {
if (!showCostTracking || !costs || !costs.perMessage || !msg?.messageId) {
return null;
}
return costs.perMessage.find((p) => p.messageId === msg.messageId) ?? null;
}, [showCostTracking, costs, msg?.messageId]);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(
@@ -159,7 +171,26 @@ const ContentRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
{perMessageCost && (
<span className="ml-2 inline-flex items-center gap-2 px-2 py-0.5 text-xs text-muted-foreground">
{perMessageCost.tokenCount > 0 && (
<span className="mr-2">
{perMessageCost.tokenType === 'prompt' ? (
<ArrowIcon direction="up" className="inline" />
) : (
<ArrowIcon direction="down" className="inline" />
)}
{localize('com_ui_token_abbreviation', {
0: perMessageCost.tokenCount,
})}
</span>
)}
<span className="whitespace-pre">${Math.abs(perMessageCost.usd).toFixed(6)}</span>
</span>
)}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import type { TConversationCosts } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
@@ -25,7 +26,7 @@ const MessageContainer = React.memo(
},
);
export default function MessageContent(props: TMessageProps) {
export default function MessageContent(props: TMessageProps & { costs?: TConversationCosts }) {
const {
showSibling,
conversation,
@@ -34,7 +35,7 @@ export default function MessageContent(props: TMessageProps) {
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
const { message, currentEditId, setCurrentEditId, costs } = props;
if (!message || typeof message !== 'object') {
return null;
@@ -53,6 +54,7 @@ export default function MessageContent(props: TMessageProps) {
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
costs={costs}
/>
<ContentRender
{...props}
@@ -60,12 +62,13 @@ export default function MessageContent(props: TMessageProps) {
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
costs={costs}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<ContentRender {...props} />
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} costs={costs} />
</div>
)}
</MessageContainer>
@@ -76,6 +79,7 @@ export default function MessageContent(props: TMessageProps) {
messagesTree={children ?? []}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
costs={costs}
/>
</>
);

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { useRecoilValue } from 'recoil';
import { TooltipAnchor } from '@librechat/client';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
@@ -9,7 +8,6 @@ import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
type BookmarkNavProps = {
tags: string[];
@@ -20,7 +18,6 @@ type BookmarkNavProps = {
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: BookmarkNavProps) => {
const localize = useLocalize();
const { data } = useGetConversationTags();
const conversation = useRecoilValue(store.conversationByIndex(0));
const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize],
@@ -56,11 +53,9 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
>
{data && conversation && (
{data && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems
// Currently selected conversation
conversation={conversation}
// List of selected tags(string)
tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag

View File

@@ -1,25 +1,16 @@
import { useEffect, useState, type FC } from 'react';
import { type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import type { TConversation } from 'librechat-data-provider';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
import { useLocalize } from '~/hooks';
const BookmarkNavItems: FC<{
conversation: TConversation;
tags: string[];
setTags: (tags: string[]) => void;
}> = ({ conversation, tags = [], setTags }) => {
const [currentConversation, setCurrentConversation] = useState<TConversation>();
}> = ({ tags = [], setTags }) => {
const { bookmarks } = useBookmarkContext();
const localize = useLocalize();
useEffect(() => {
if (!currentConversation) {
setCurrentConversation(conversation);
}
}, [conversation, currentConversation]);
const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag);

View File

@@ -76,6 +76,13 @@ const toggleSwitchConfigs = [
hoverCardText: undefined,
key: 'modularChat',
},
{
stateAtom: store.showCostTracking,
localizationKey: 'com_nav_show_cost_tracking',
switchId: 'showCostTracking',
hoverCardText: 'com_nav_info_show_cost_tracking',
key: 'showCostTracking',
},
];
function Chat() {

View File

@@ -47,11 +47,7 @@ export default function AgentPanel() {
const { onSelect: onSelectAgent } = useSelectAgent();
const modelsQuery = useGetModelsQuery();
// Basic agent query for initial permission check
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
});
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id);
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
ResourceType.AGENT,

View File

@@ -26,10 +26,6 @@ function AgentPanelSwitchWithContext() {
}
}, [setCurrentAgentId, conversation?.agent_id]);
if (!conversation?.endpoint) {
return null;
}
if (activePanel === Panel.actions) {
return <ActionsPanel />;
}

View File

@@ -4,7 +4,6 @@ import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
Checkbox,
@@ -14,20 +13,18 @@ import {
AccordionItem,
CircleHelpIcon,
OGDialogTrigger,
useToastContext,
AccordionContent,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { removeTool } = useRemoveMCPTool();
const { getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
@@ -56,36 +53,6 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
);
};
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentServerName;

View File

@@ -91,6 +91,14 @@ export default function ApiKeyDialog({
text: localize('com_ui_web_search_reranker_jina_key'),
},
},
jinaApiUrl: {
placeholder: localize('com_ui_web_search_jina_url'),
type: 'text' as const,
link: {
url: 'https://api.jina.ai/v1/rerank',
text: localize('com_ui_web_search_reranker_jina_url_help'),
},
},
},
},
{

View File

@@ -1,25 +1,12 @@
import React, { useState } from 'react';
import { CircleX } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import { useLocalize, useRemoveMCPTool } from '~/hooks';
import { cn } from '~/utils';
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { removeTool } = useRemoveMCPTool();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
@@ -28,36 +15,6 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
return null;
}
const removeTool = () => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
return (
<OGDialog>
<div
@@ -116,7 +73,7 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
</Label>
}
selection={{
selectHandler: () => removeTool(),
selectHandler: () => removeTool(serverName || ''),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),

View File

@@ -1,29 +1,18 @@
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
import type { MCPServerInfo } from '~/common';
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { removeTool } = useRemoveMCPTool();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } =
useMCPServerManager();
@@ -31,39 +20,6 @@ export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPS
return null;
}
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
const serverName = serverInfo.serverName;
const isServerInitializing = isInitializing(serverName);
const statusIconProps = getServerStatusIconProps(serverName);

View File

@@ -16,14 +16,7 @@ export default function VersionPanel() {
const selectedAgentId = agent_id ?? '';
const {
data: agent,
isLoading,
error,
refetch,
} = useGetAgentByIdQuery(selectedAgentId, {
enabled: !!selectedAgentId && selectedAgentId !== '',
});
const { data: agent, isLoading, error, refetch } = useGetAgentByIdQuery(selectedAgentId);
const revertAgentVersion = useRevertAgentVersionMutation({
onSuccess: () => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button, useToastContext } from '@librechat/client';
import { Constants, QueryKeys } from 'librechat-data-provider';
@@ -123,6 +123,7 @@ function MCPPanelContent() {
}
const serverStatus = connectionStatus?.[selectedServerNameForEditing];
const isConnected = serverStatus?.connectionState === 'connected';
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
@@ -159,6 +160,17 @@ function MCPPanelContent() {
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
/>
{serverStatus?.requiresOAuth && isConnected && (
<Button
className="w-full"
size="sm"
variant="destructive"
onClick={() => handleConfigRevoke(selectedServerNameForEditing)}
>
<Trash2 className="h-4 w-4" />
{localize('com_ui_oauth_revoke')}
</Button>
)}
</div>
);
} else {

View File

@@ -1,5 +1,11 @@
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { QueryKeys, dataService, EModelEndpoint, PermissionBits } from 'librechat-data-provider';
import {
Constants,
QueryKeys,
dataService,
EModelEndpoint,
PermissionBits,
} from 'librechat-data-provider';
import type {
QueryObserverResult,
UseQueryOptions,
@@ -64,20 +70,27 @@ export const useListAgentsQuery = <TData = t.AgentListResponse>(
* Hook for retrieving basic details about a single agent (VIEW permission)
*/
export const useGetAgentByIdQuery = (
agent_id: string,
agent_id: string | null | undefined,
config?: UseQueryOptions<t.Agent>,
): QueryObserverResult<t.Agent> => {
const isValidAgentId = !!(
agent_id &&
agent_id !== '' &&
agent_id !== Constants.EPHEMERAL_AGENT_ID
);
return useQuery<t.Agent>(
[QueryKeys.agent, agent_id],
() =>
dataService.getAgentById({
agent_id,
agent_id: agent_id as string,
}),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
enabled: isValidAgentId && (config?.enabled ?? true),
...config,
},
);

View File

@@ -0,0 +1,180 @@
import { renderHook } from '@testing-library/react';
import { Tools } from 'librechat-data-provider';
import useAgentToolPermissions from '../useAgentToolPermissions';
// Mock the dependencies
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(),
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: jest.fn(),
}));
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
const mockUseAgentsMapContext = jest.requireMock('~/Providers').useAgentsMapContext;
describe('useAgentToolPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when no agentId is provided', () => {
it('should allow all tools for ephemeral agents', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(null));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should allow all tools when agentId is undefined', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(undefined));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should allow all tools when agentId is empty string', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(''));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
});
describe('when agentId is provided but agent not found', () => {
it('should disallow all tools', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions('non-existent-agent'));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
describe('when agent is found with tools', () => {
it('should allow tools that are included in the agent tools array', () => {
const agentId = 'test-agent';
const agent = {
id: agentId,
tools: [Tools.file_search],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([Tools.file_search]);
});
it('should allow both tools when both are included', () => {
const agentId = 'test-agent';
const agent = {
id: agentId,
tools: [Tools.file_search, Tools.execute_code],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.file_search, Tools.execute_code]);
});
it('should use data from API query when available', () => {
const agentId = 'test-agent';
const agentMapData = {
id: agentId,
tools: [Tools.file_search],
};
const agentApiData = {
id: agentId,
tools: [Tools.execute_code, Tools.file_search],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agentMapData });
mockUseGetAgentByIdQuery.mockReturnValue({ data: agentApiData });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
// API data should take precedence
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.execute_code, Tools.file_search]);
});
it('should fallback to agent map data when API data is not available', () => {
const agentId = 'test-agent';
const agentMapData = {
id: agentId,
tools: [Tools.execute_code],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agentMapData });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toEqual([Tools.execute_code]);
});
});
describe('when agent has no tools', () => {
it('should disallow all tools with empty array', () => {
const agentId = 'test-agent';
const agent = {
id: agentId,
tools: [],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([]);
});
it('should disallow all tools with undefined tools', () => {
const agentId = 'test-agent';
const agent = {
id: agentId,
tools: undefined,
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(agentId));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
});

View File

@@ -5,3 +5,4 @@ export type { ProcessedAgentCategory } from './useAgentCategories';
export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
export { default as useAgentToolPermissions } from './useAgentToolPermissions';

View File

@@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { Tools } from 'librechat-data-provider';
import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
interface AgentToolPermissionsResult {
fileSearchAllowedByAgent: boolean;
codeAllowedByAgent: boolean;
tools: string[] | undefined;
}
/**
* Hook to determine whether specific tools are allowed for a given agent.
*
* @param agentId - The ID of the agent. If null/undefined/empty, returns true for all tools (ephemeral agent behavior)
* @returns Object with boolean flags for file_search and execute_code permissions, plus the tools array
*/
export default function useAgentToolPermissions(
agentId: string | null | undefined,
): AgentToolPermissionsResult {
const agentsMap = useAgentsMapContext();
const selectedAgent = useMemo(() => {
return agentId != null && agentId !== '' ? agentsMap?.[agentId] : undefined;
}, [agentId, agentsMap]);
const { data: agentData } = useGetAgentByIdQuery(agentId);
const tools = useMemo(
() =>
(agentData?.tools as string[] | undefined) || (selectedAgent?.tools as string[] | undefined),
[agentData?.tools, selectedAgent?.tools],
);
const fileSearchAllowedByAgent = useMemo(() => {
// If no agentId, allow for ephemeral agents
if (!agentId) return true;
// If agentId exists but agent not found, disallow
if (!selectedAgent) return false;
// Check if the agent has the file_search tool
return tools?.includes(Tools.file_search) ?? false;
}, [agentId, selectedAgent, tools]);
const codeAllowedByAgent = useMemo(() => {
// If no agentId, allow for ephemeral agents
if (!agentId) return true;
// If agentId exists but agent not found, disallow
if (!selectedAgent) return false;
// Check if the agent has the execute_code tool
return tools?.includes(Tools.execute_code) ?? false;
}, [agentId, selectedAgent, tools]);
return {
fileSearchAllowedByAgent,
codeAllowedByAgent,
tools,
};
}

View File

@@ -22,9 +22,7 @@ export default function useSelectAgent() {
conversation?.agent_id ?? null,
);
const agentQuery = useGetAgentByIdQuery(selectedAgentId ?? '', {
enabled: !!(selectedAgentId ?? '') && selectedAgentId !== Constants.EPHEMERAL_AGENT_ID,
});
const agentQuery = useGetAgentByIdQuery(selectedAgentId);
const updateConversation = useCallback(
(agent: Partial<Agent>, template: Partial<TPreset | TConversation>) => {

View File

@@ -5,6 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
import { cleanupTimestampedStorage } from '~/utils/timestamps';
import useSpeechSettingsInit from './useSpeechSettingsInit';
import store from '~/store';
@@ -34,6 +35,11 @@ export default function useAppStartup({
useSpeechSettingsInit(!!user);
/** Clean up old localStorage entries on startup */
useEffect(() => {
cleanupTimestampedStorage();
}, []);
/** Set the app title */
useEffect(() => {
const appTitle = startupConfig?.appTitle ?? '';

View File

@@ -1,9 +1,10 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useRef } from 'react';
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
Tools,
QueryKeys,
Constants,
EModelEndpoint,
@@ -26,19 +27,6 @@ export default function useDragHelpers() {
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
);
const handleOptionSelect = (toolResource: EToolResources | undefined) => {
/** File search is not automatically enabled to simulate legacy behavior */
if (toolResource && toolResource !== EToolResources.file_search) {
setEphemeralAgent((prev) => ({
...prev,
[toolResource]: true,
}));
}
handleFiles(draggedFiles, toolResource);
setShowModal(false);
setDraggedFiles([]);
};
const isAssistants = useMemo(
() => isAssistantsEndpoint(conversation?.endpoint),
[conversation?.endpoint],
@@ -48,36 +36,95 @@ export default function useDragHelpers() {
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
});
const handleOptionSelect = useCallback(
(toolResource: EToolResources | undefined) => {
/** File search is not automatically enabled to simulate legacy behavior */
if (toolResource && toolResource !== EToolResources.file_search) {
setEphemeralAgent((prev) => ({
...prev,
[toolResource]: true,
}));
}
handleFiles(draggedFiles, toolResource);
setShowModal(false);
setDraggedFiles([]);
},
[draggedFiles, handleFiles, setEphemeralAgent],
);
/** Use refs to avoid re-creating the drop handler */
const handleFilesRef = useRef(handleFiles);
const conversationRef = useRef(conversation);
handleFilesRef.current = handleFiles;
conversationRef.current = conversation;
const handleDrop = useCallback(
(item: { files: File[] }) => {
if (isAssistants) {
handleFilesRef.current(item.files);
return;
}
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true;
/** Get agent permissions at drop time */
const agentId = conversationRef.current?.agent_id;
let fileSearchAllowedByAgent = true;
let codeAllowedByAgent = true;
if (agentId && agentId !== Constants.EPHEMERAL_AGENT_ID) {
/** Agent data from cache */
const agent = queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId]);
if (agent) {
const agentTools = agent.tools as string[] | undefined;
fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false;
codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false;
} else {
/** If agent exists but not found, disallow */
fileSearchAllowedByAgent = false;
codeAllowedByAgent = false;
}
}
/** Determine if dragged files are all images (enables the base image option) */
const allImages = item.files.every((f) => f.type?.startsWith('image/'));
const shouldShowModal =
allImages ||
(fileSearchEnabled && fileSearchAllowedByAgent) ||
(codeEnabled && codeAllowedByAgent) ||
ocrEnabled;
if (!shouldShowModal) {
// Fallback: directly handle files without showing modal
handleFilesRef.current(item.files);
return;
}
setDraggedFiles(item.files);
setShowModal(true);
},
[isAssistants, queryClient],
);
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: [NativeTypes.FILE],
drop(item: { files: File[] }) {
console.log('drop', item.files);
if (isAssistants) {
handleFiles(item.files);
return;
}
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true;
if (!codeEnabled && !fileSearchEnabled && !ocrEnabled) {
handleFiles(item.files);
return;
}
setDraggedFiles(item.files);
setShowModal(true);
},
drop: handleDrop,
canDrop: () => true,
collect: (monitor: DropTargetMonitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
collect: (monitor: DropTargetMonitor) => {
/** Optimize collect to reduce re-renders */
const isOver = monitor.isOver();
const canDrop = monitor.canDrop();
return { isOver, canDrop };
},
}),
[handleFiles],
[handleDrop],
);
return {

View File

@@ -125,13 +125,8 @@ export default function useQueryParams({
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
// Extract agent_id from URL for proactive fetching
const urlAgentId = searchParams.get('agent_id') || '';
// Use the existing query hook to fetch agent if present in URL
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId, {
enabled: !!urlAgentId, // Only fetch if agent_id exists in URL
});
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId);
/**
* Applies settings from URL query parameters to create a new conversation.

View File

@@ -3,3 +3,4 @@ export * from './useMCPConnectionStatus';
export * from './useMCPSelect';
export * from './useVisibleTools';
export { useMCPServerManager } from './useMCPServerManager';
export { useRemoveMCPTool } from './useRemoveMCPTool';

View File

@@ -1,66 +1,50 @@
import { useRef, useCallback, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useAtom } from 'jotai';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue.length > 2) {
return true;
}
} catch (e) {
console.error(e);
}
}
return Array.isArray(value) && value.length > 0;
};
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
import { setTimestamp } from '~/utils/timestamps';
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
const key = conversationId ?? Constants.NEW_CONVO;
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
const mcpState = useMemo(() => {
return ephemeralAgent?.mcp ?? [];
}, [ephemeralAgent?.mcp]);
// Sync Jotai state with ephemeral agent state
useEffect(() => {
if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) {
setMCPValuesRaw(ephemeralAgent.mcp);
}
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
const setSelectedValues = useCallback(
(values: string[] | null | undefined) => {
if (!values) {
return;
}
if (!Array.isArray(values)) {
return;
}
// Update ephemeral agent when Jotai state changes
useEffect(() => {
if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) {
setEphemeralAgent((prev) => ({
...prev,
mcp: values,
mcp: mcpValues,
}));
}
}, [mcpValues, ephemeralAgent?.mcp, setEphemeralAgent]);
useEffect(() => {
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
if (mcpValues.length > 0) {
setTimestamp(mcpStorageKey);
}
}, [mcpValues, key]);
/** Stable memoized setter */
const setMCPValues = useCallback(
(value: string[]) => {
if (!Array.isArray(value)) {
return;
}
setMCPValuesRaw(value);
},
[setEphemeralAgent],
);
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
storageKey,
mcpState,
setSelectedValues,
storageCondition,
);
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
setMCPValuesRawRef.current = setMCPValuesRaw;
/** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */
const setMCPValues = useCallback((value: string[]) => {
setMCPValuesRawRef.current(value);
}, []);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
`${LocalStorageKeys.PIN_MCP_}${key}`,
true,
[setMCPValuesRaw],
);
return {

View File

@@ -25,9 +25,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { mcpToolDetails } = useGetMCPTools();
const mcpSelect = useMCPSelect({ conversationId });
const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
/**
* Hook for removing MCP tools/servers from an agent
* Provides unified logic for MCPTool, UninitializedMCPTool, and UnconfiguredMCPTool components
*/
export function useRemoveMCPTool() {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const removeTool = useCallback(
(serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds, { shouldDirty: true });
showToast({
message: localize('com_ui_delete_tool_save_reminder'),
status: 'warning',
});
},
},
);
},
[getValues, setValue, updateUserPlugins, showToast, localize],
);
return { removeTool };
}

View File

@@ -19,6 +19,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const { conversationId } = conversation ?? {};
const timeoutIdRef = useRef<NodeJS.Timeout>();
const prevIsSubmittingRef = useRef<boolean>(false);
const debouncedSetShowScrollButton = useCallback((value: boolean) => {
clearTimeout(timeoutIdRef.current);
@@ -60,7 +61,10 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
}
}, [debouncedSetShowScrollButton]);
const scrollCallback = () => debouncedSetShowScrollButton(false);
const scrollCallback = useCallback(
() => debouncedSetShowScrollButton(false),
[debouncedSetShowScrollButton],
);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
@@ -71,6 +75,18 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
},
});
const smoothScrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest',
});
scrollCallback();
setAbortScroll(false);
}
}, [scrollCallback, setAbortScroll]);
useEffect(() => {
if (!messagesTree || messagesTree.length === 0) {
return;
@@ -91,6 +107,20 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
};
}, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;
}
if (prevIsSubmittingRef.current && !isSubmitting && abortScroll !== true) {
setTimeout(() => {
smoothScrollToBottom();
}, 100);
}
prevIsSubmittingRef.current = isSubmitting;
}, [isSubmitting, smoothScrollToBottom, abortScroll]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;

View File

@@ -15,6 +15,7 @@ export type SearchApiKeyFormData = {
firecrawlApiKey: string;
firecrawlApiUrl: string;
jinaApiKey: string;
jinaApiUrl: string;
cohereApiKey: string;
};
@@ -54,6 +55,7 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
firecrawlApiKey: data.firecrawlApiKey,
firecrawlApiUrl: data.firecrawlApiUrl,
jinaApiKey: data.jinaApiKey,
jinaApiUrl: data.jinaApiUrl,
cohereApiKey: data.cohereApiKey,
}).reduce(
(acc, [key, value]) => {

View File

@@ -5,23 +5,10 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import type { VerifyToolAuthResponse } from 'librechat-data-provider';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { setTimestamp } from '~/utils/timestamps';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null;
};
type ToolValue = boolean | string;
interface UseToolToggleOptions {
@@ -39,7 +26,7 @@ interface UseToolToggleOptions {
export function useToolToggle({
conversationId,
toolKey,
toolKey: _toolKey,
localStorageKey,
isAuthenticated: externalIsAuthenticated,
setIsDialogOpen,
@@ -62,13 +49,8 @@ export function useToolToggle({
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
);
// Keep localStorage in sync
const [, setLocalStorageValue] = useLocalStorage<ToolValue>(
`${localStorageKey}${key}`,
false,
undefined,
storageCondition,
);
const toolKey = useMemo(() => _toolKey, [_toolKey]);
const storageKey = useMemo(() => `${localStorageKey}${key}`, [localStorageKey, key]);
// The actual current value comes from ephemeralAgent
const toolValue = useMemo(() => {
@@ -83,13 +65,14 @@ export function useToolToggle({
return toolValue === true;
}, [toolValue]);
// Sync to localStorage when ephemeralAgent changes
// Sync to localStorage with timestamps when ephemeralAgent changes
useEffect(() => {
const value = ephemeralAgent?.[toolKey];
if (value !== undefined) {
setLocalStorageValue(value);
localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
}
}, [ephemeralAgent, toolKey, setLocalStorageValue]);
}, [ephemeralAgent, toolKey, storageKey]);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);

View File

@@ -232,8 +232,14 @@ export default function useEventHandlers({
},
]);
}
if (userMessage?.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, userMessage.conversationId, 'costs'],
});
}
},
[setMessages, announcePolite, setIsSubmitting],
[setMessages, announcePolite, setIsSubmitting, queryClient],
);
const cancelHandler = useCallback(
@@ -275,6 +281,12 @@ export default function useEventHandlers({
});
}
if (convoUpdate?.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, convoUpdate.conversationId, 'costs'],
});
}
setIsSubmitting(false);
},
[setMessages, setConversation, genTitle, isAddedRequest, queryClient, setIsSubmitting],
@@ -341,6 +353,12 @@ export default function useEventHandlers({
if (resetLatestMessage) {
resetLatestMessage();
}
if (conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, conversationId, 'costs'],
});
}
},
[
queryClient,
@@ -527,6 +545,12 @@ export default function useEventHandlers({
);
}
if (conversation.conversationId) {
queryClient.invalidateQueries({
queryKey: [QueryKeys.conversation, conversation.conversationId, 'costs'],
});
}
if (isNewConvo && submissionConvo.conversationId) {
removeConvoFromAllQueries(queryClient, submissionConvo.conversationId);
}

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from 'lodash';
interface UseInfiniteScrollOptions {
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isLoading?: boolean;
fetchNextPage: () => void;
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
throttleMs?: number; // Throttle delay in milliseconds
@@ -15,77 +15,95 @@ interface UseInfiniteScrollOptions {
*/
export const useInfiniteScroll = ({
hasNextPage = false,
isFetchingNextPage = false,
isLoading = false,
fetchNextPage,
threshold = 0.8, // Trigger when 80% scrolled
throttleMs = 200,
}: UseInfiniteScrollOptions) => {
const scrollElementRef = useRef<HTMLElement | null>(null);
// Monitor resizing of the scroll container
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [scrollElement, setScrollElementState] = useState<HTMLElement | null>(null);
// Throttled scroll handler to prevent excessive API calls
const handleScroll = useCallback(
throttle(() => {
const element = scrollElementRef.current;
if (!element) return;
// Handler to check if we need to fetch more data
const handleNeedToFetch = useCallback(() => {
if (!scrollElement) return;
const { scrollTop, scrollHeight, clientHeight } = element;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
// Calculate scroll position as percentage
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
// Calculate scroll position as percentage
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
// Check if we've scrolled past the threshold and conditions are met
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage;
// Check if we've scrolled past the threshold and conditions are met
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isLoading;
if (shouldFetch) {
fetchNextPage();
}
}, throttleMs),
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
if (shouldFetch) {
fetchNextPage();
}
}, [scrollElement, hasNextPage, isLoading, fetchNextPage, threshold]);
// Create a throttled version - using useMemo to ensure it's created synchronously
const throttledHandleNeedToFetch = useMemo(
() => throttle(handleNeedToFetch, throttleMs),
[handleNeedToFetch, throttleMs],
);
// Set up scroll listener
// Clean up throttled function on unmount
useEffect(() => {
const element = scrollElementRef.current;
return () => {
throttledHandleNeedToFetch.cancel?.();
};
}, [throttledHandleNeedToFetch]);
// Check if we need to fetch more data when loading state changes (useful to fill content on first load)
useEffect(() => {
if (isLoading === false && scrollElement) {
// Use requestAnimationFrame to ensure DOM is ready after loading completes
const rafId = requestAnimationFrame(() => {
throttledHandleNeedToFetch();
});
return () => cancelAnimationFrame(rafId);
}
}, [isLoading, scrollElement, throttledHandleNeedToFetch]);
// Set up scroll listener and ResizeObserver
useEffect(() => {
const element = scrollElement;
if (!element) return;
// Remove any existing listener first
element.removeEventListener('scroll', handleScroll);
// Add the scroll listener
element.addEventListener('scroll', throttledHandleNeedToFetch, { passive: true });
// Add the new listener
element.addEventListener('scroll', handleScroll, { passive: true });
// Set up ResizeObserver to detect size changes
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
resizeObserverRef.current = new ResizeObserver(() => {
// Check if we need to fetch more data when container resizes
throttledHandleNeedToFetch();
});
resizeObserverRef.current.observe(element);
// Check immediately when element changes
throttledHandleNeedToFetch();
return () => {
element.removeEventListener('scroll', handleScroll);
// Clean up throttled function
handleScroll.cancel?.();
element.removeEventListener('scroll', throttledHandleNeedToFetch);
// Clean up ResizeObserver
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
};
}, [handleScroll]);
// Additional effect to re-setup listeners when scroll element changes
useEffect(() => {
const element = scrollElementRef.current;
if (!element) return;
// Remove any existing listener first
element.removeEventListener('scroll', handleScroll);
// Add the new listener
element.addEventListener('scroll', handleScroll, { passive: true });
return () => {
element.removeEventListener('scroll', handleScroll);
// Clean up throttled function
handleScroll.cancel?.();
};
}, [scrollElementRef.current, handleScroll]);
}, [scrollElement, throttledHandleNeedToFetch]);
// Function to manually set the scroll container
const setScrollElement = useCallback((element: HTMLElement | null) => {
scrollElementRef.current = element;
setScrollElementState(element);
}, []);
return {
setScrollElement,
scrollElementRef,
};
};
export default useInfiniteScroll;

View File

@@ -31,7 +31,7 @@ export default function useScrollToRef({
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRef = useCallback(
throttle(() => logAndScroll('instant', callback), 145, { leading: true }),
throttle(() => logAndScroll('instant', callback), 100, { leading: true }),
[targetRef],
);

View File

@@ -568,6 +568,8 @@
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_show_code": "Always show code when using code interpreter",
"com_nav_show_cost_tracking": "Show cost tracking",
"com_nav_info_show_cost_tracking": "Display conversation costs and per-message cost breakdowns",
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
@@ -834,7 +836,7 @@
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_error": "Error while deleting the tool: {{error}}",
"com_ui_delete_tool_success": "Tool deleted successfully",
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
@@ -848,7 +850,7 @@
"com_ui_download_backup": "Download Backup Codes",
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
"com_ui_download_error": "Error downloading file. The file may have been deleted.",
"com_ui_drag_drop": "something needs to go here. was empty",
"com_ui_drag_drop": "Drop any file here to add it to the conversation",
"com_ui_dropdown_variables": "Dropdown variables:",
"com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "Duplicate",
@@ -1044,6 +1046,7 @@
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
"com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_revoke": "Revoke",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful",
"com_ui_of": "of",
@@ -1196,6 +1199,7 @@
"com_ui_thinking": "Thinking...",
"com_ui_thoughts": "Thoughts",
"com_ui_token": "token",
"com_ui_token_abbreviation": "{{0}}t",
"com_ui_token_exchange_method": "Token Exchange Method",
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",
@@ -1251,6 +1255,7 @@
"com_ui_web_search_cohere_key": "Enter Cohere API Key",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)",
"com_ui_web_search_jina_key": "Enter Jina API Key",
"com_ui_web_search_jina_url": "Jina API URL (optional)",
"com_ui_web_search_processing": "Processing results",
"com_ui_web_search_provider": "Search Provider",
"com_ui_web_search_provider_searxng": "SearXNG",
@@ -1262,6 +1267,7 @@
"com_ui_web_search_reranker_cohere_key": "Get your Cohere API key",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "Get your Jina API key",
"com_ui_web_search_reranker_jina_url_help": "Learn about Jina Rerank API",
"com_ui_web_search_scraper": "Scraper",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key",

View File

@@ -35,7 +35,7 @@
"com_agents_created_by": "izveidojis",
"com_agents_description_placeholder": "Pēc izvēles: aprakstiet savu aģentu šeit",
"com_agents_empty_state_heading": "Nav atrasts neviens aģents",
"com_agents_enable_file_search": "Iespējot failu meklēšanu",
"com_agents_enable_file_search": "Iespējot faila augšupielādi informācijas iegūšanai no tā",
"com_agents_error_bad_request_message": "Pieprasījumu nevarēja apstrādāt.",
"com_agents_error_bad_request_suggestion": "Lūdzu, pārbaudiet ievadītos datus un mēģiniet vēlreiz.",
"com_agents_error_category_title": "Kategorija Kļūda",
@@ -62,7 +62,7 @@
"com_agents_file_context": "Failu konteksts (OCR)",
"com_agents_file_context_disabled": "Pirms failu augšupielādes failu kontekstam ir jāizveido aģents.",
"com_agents_file_context_info": "Faili, kas augšupielādēti kā “Konteksts”, tiek apstrādāti, izmantojot OCR, lai iegūtu tekstu, kas pēc tam tiek pievienots aģenta norādījumiem. Ideāli piemērots dokumentiem, attēliem ar tekstu vai PDF failiem, kuriem nepieciešams pilns faila teksta saturs.",
"com_agents_file_search_disabled": "Pirms failu augšupielādes failu meklēšanai ir jāizveido aģents.",
"com_agents_file_search_disabled": "Lai varētu iespējot faila augšupielādi informācijas iegūšanai no tā ir jāizveido aģents.",
"com_agents_file_search_info": "Kad šī opcija ir iespējota, aģents tiks informēts par precīziem tālāk norādītajiem failu nosaukumiem, ļaujot tam izgūt atbilstošu kontekstu no šiem failiem.",
"com_agents_grid_announcement": "Rādu {{count}} aģentus {{category}} kategorijā",
"com_agents_instructions_placeholder": "Sistēmas instrukcijas, ko izmantos aģents",
@@ -124,17 +124,17 @@
"com_assistants_delete_actions_success": "Darbība veiksmīgi dzēsta no asistenta",
"com_assistants_description_placeholder": "Pēc izvēles: Šeit aprakstiet savu asistentu",
"com_assistants_domain_info": "Asistents nosūtīja šo informāciju {{0}}",
"com_assistants_file_search": "Failu meklēšana",
"com_assistants_file_search_info": "Failu meklēšana ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
"com_assistants_file_search": "Faila augšupielāde informācijas iegūšanai no tā",
"com_assistants_file_search_info": "Faila augšupielāde informācijas iegūšanai no tā ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
"com_assistants_function_use": "Izmantotais asistents {{0}}",
"com_assistants_image_vision": "Attēla redzējums",
"com_assistants_instructions_placeholder": "Sistēmas norādījumi, ko izmanto asistents",
"com_assistants_knowledge": "Zināšanas",
"com_assistants_knowledge_disabled": "Pirms failu augšupielādes kā Zināšanas ir jāizveido asistents un jāaktivizē un jāsaglabā koda interpretētājs vai failu atgūšana.",
"com_assistants_knowledge_disabled": "Pirms failu var augšupielādēt zināšanu krātuvē ir jāizveido asistents un jāaktivizē koda interpretētājs vai failu atgūšana.",
"com_assistants_knowledge_info": "Ja augšupielādējat failus sadaļā Zināšanas, sarunās ar asistentu var tikt iekļauts faila saturs.",
"com_assistants_max_starters_reached": "Sasniegts maksimālais sarunu uzsākšanas iespēju skaits",
"com_assistants_name_placeholder": "Pēc izvēles: Asistenta nosaukums",
"com_assistants_non_retrieval_model": "Šajā modelī failu meklēšana nav iespējota. Lūdzu, izvēlieties citu modeli.",
"com_assistants_non_retrieval_model": "Šajā modelī faila augšupielāde informācijas iegūšana no tā nav iespējota. Lūdzu, izvēlieties citu modeli.",
"com_assistants_retrieval": "Atgūšana",
"com_assistants_running_action": "Darbība palaista",
"com_assistants_running_var": "Strādā {{0}}",
@@ -143,7 +143,7 @@
"com_assistants_update_actions_success": "Veiksmīgi izveidota vai atjaunināta darbība",
"com_assistants_update_error": "Jūsu asistenta atjaunināšanā notika kļūda.",
"com_assistants_update_success": "Veiksmīgi atjaunināts",
"com_auth_already_have_account": "Jau ir konts?",
"com_auth_already_have_account": "Vai jums jau ir konts?",
"com_auth_apple_login": "Pierakstīties, izmantojot Apple",
"com_auth_back_to_login": "Atgriezties pie pieteikšanās",
"com_auth_click": "Noklikšķiniet uz",
@@ -298,16 +298,16 @@
"com_endpoint_openai_max_tokens": "Pēc izvēles “max_tokens” lauks, kas norāda maksimālo tokenu skaitu, ko var ģenerēt sarunas pabeigšanas laikā. Ievades tokenu un ģenerēto tokenu kopējo garumu ierobežo modeļa konteksta garums. Ja šis skaitlis pārsniedz maksimālo konteksta tokenu skaitu, var rasties kļūdas.",
"com_endpoint_openai_pres": "Skaitlis no -2,0 līdz 2,0. Pozitīvas vērtības soda jaunus tokenus, pamatojoties uz to, vai tie līdz šim parādās tekstā, palielinot modeļa iespējamību runāt par jaunām tēmām.",
"com_endpoint_openai_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas, kas jāiekļauj sistēmas ziņā. Noklusējuma vērtība: nav",
"com_endpoint_openai_reasoning_effort": "Tikai o1 un o3 modeļi: ierobežo spriešanas modeļu spriešanas piepūli. Spriešanas piepūles samazināšana var nodrošināt ātrākas atbildes un mazāk spriešanas tokenus izmantošanas atbildē.",
"com_endpoint_openai_reasoning_effort": "Spriešanas modeļi tikai: ierobežo spriešanas modeļu spriešanas piepūli. Spriešanas piepūles samazināšana var nodrošināt ātrākas atbildes un mazāk spriešanas tokenus izmantošanas atbildē.",
"com_endpoint_openai_reasoning_summary": "Tikai atbilžu API: modeļa veiktās spriešanas kopsavilkums. Tas var būt noderīgi atkļūdošanai un modeļa spriešanas procesa izpratnei. Iestatiet vērtību “nav”, “automātiski”, “kodolīgs” vai “detalizēts”.",
"com_endpoint_openai_resend": "Nosūtiet vēlreiz visus iepriekš pievienotos attēlus. Piezīme. Tas var ievērojami palielināt tokena izmaksas, un ar daudziem attēlu pielikumiem var rasties kļūdas.",
"com_endpoint_openai_resend_files": "Nosūtiet vēlreiz visus iepriekš pievienotos failus. Piezīme. Tas palielinās tokena izmaksas, un ar daudziem pielikumiem var rasties kļūdas.",
"com_endpoint_openai_stop": "Līdz 4 secībām, kurās API pārtrauks turpmāku tokenu ģenerēšanu.",
"com_endpoint_openai_stop": "Līdz 4 sekvencēm, kurās API pārtrauks turpmāku tokenu ģenerēšanu.",
"com_endpoint_openai_temp": "Augstākas vērtības = nejaušāks, savukārt zemākas vērtības = fokusētāks un deterministiskāks. Iesakām mainīt šo vai Top P, bet ne abus.",
"com_endpoint_openai_topp": "Alternatīva izlasei ar temperatūru, ko sauc par kodola izlasi, kur modelis ņem vērā tokenu rezultātus ar varbūtības masu top_p. Tātad 0,1 nozīmē, ka tiek ņemti vērā tikai tie tokeni, kas veido augšējo 10% varbūtības masu. Mēs iesakām mainīt šo vai temperatūru, bet ne abus.",
"com_endpoint_openai_use_responses_api": "Izmantot Response API sarunas pabeigšanas vietā, kas ietver paplašinātas OpenAI funkcijas. Nepieciešams o1-pro, o3-pro un spriešanas kopsavilkumu iespējošanai.",
"com_endpoint_openai_use_web_search": "Iespējojiet tīmekļa meklēšanas funkcionalitāti, izmantojot OpenAI iebūvētās meklēšanas iespējas. Tas ļauj modelim meklēt tīmeklī aktuālu informāciju un sniegt precīzākas, aktuālākas atbildes.",
"com_endpoint_openai_verbosity": "Ierobežo modeļa atbildes plašumu. Zemākas vērtības nodrošinās kodolīgākas atbildes, savukārt augstākas vērtības nodrošinās plašākas atbildes. Pašlaik atbalstītās vērtības ir zema, vidēja un augsta.",
"com_endpoint_openai_verbosity": "Ierobežo modeļa atbildes plašumu. Zemākas vērtības nodrošinās kodolīgākas atbildes, savukārt augstākas vērtības nodrošinās plašākas atbildes. Pašlaik atbalstītās vērtības ir zems, vidējs un augsts.",
"com_endpoint_output": "Izvade",
"com_endpoint_plug_image_detail": "Attēla detaļas",
"com_endpoint_plug_resend_files": "Atkārtoti nosūtīt failus",
@@ -337,8 +337,8 @@
"com_endpoint_prompt_prefix_assistants": "Papildu instrukcijas",
"com_endpoint_prompt_prefix_assistants_placeholder": "Iestatiet papildu norādījumus vai kontekstu virs Asistenta galvenajiem norādījumiem. Ja lauks ir tukšs, tas tiek ignorēts.",
"com_endpoint_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas vai kontekstu. Ja lauks ir tukšs, tas tiek ignorēts.",
"com_endpoint_reasoning_effort": "Domāšanas grūtums",
"com_endpoint_reasoning_summary": "Argumentācijas kopsavilkums",
"com_endpoint_reasoning_effort": "Spriešanas piepūle",
"com_endpoint_reasoning_summary": "Spriešanas kopsavilkums",
"com_endpoint_save_as_preset": "Saglabāt kā iestatījumu",
"com_endpoint_search": "Meklēt galapunktu pēc nosaukuma",
"com_endpoint_search_endpoint_models": "Meklēt {{0}} modeļos...",
@@ -346,7 +346,7 @@
"com_endpoint_search_var": "Meklēt {{0}}...",
"com_endpoint_set_custom_name": "Iestatiet pielāgotu nosaukumu, ja varat atrast šo iestatījumu",
"com_endpoint_skip_hover": "Iespējot pabeigšanas soļa izlaišanu, kurā tiek pārskatīta galīgā atbilde un ģenerētie soļi",
"com_endpoint_stop": "Apturēt secības",
"com_endpoint_stop": "Stop sekvences",
"com_endpoint_stop_placeholder": "Atdaliet vērtības, nospiežot taustiņu `Enter`",
"com_endpoint_temperature": "Temperatūra",
"com_endpoint_thinking": "Domāšana",
@@ -443,7 +443,7 @@
"com_nav_clear_cache_confirm_message": "Vai tiešām vēlaties notīrīt kešatmiņu?",
"com_nav_clear_conversation": "Skaidras sarunas",
"com_nav_clear_conversation_confirm_message": "Vai tiešām vēlaties dzēst visas saglabātās sarunas? Šī darbība ir neatgriezeniska.",
"com_nav_close_sidebar": "Aizvērt sānu joslu",
"com_nav_close_sidebar": "Aizvērt sāna joslu",
"com_nav_commands": "Komandas",
"com_nav_confirm_clear": "Apstiprināt dzēšanu",
"com_nav_conversation_mode": "Sarunas režīms",
@@ -478,7 +478,7 @@
"com_nav_font_size_xl": "Īpaši liels",
"com_nav_font_size_xs": "Īpaši mazs",
"com_nav_help_faq": "Palīdzība un bieži uzdotie jautājumi",
"com_nav_hide_panel": "Slēpt labo sāna paneli",
"com_nav_hide_panel": "Slēpt labā sāna paneli",
"com_nav_info_balance": "Bilance parāda, cik daudz tokenu kredītu jums ir atlicis izmantot. Tokenu kredīti tiek pārvērsti naudas vērtībā (piemēram, 1000 kredīti = 0,001 USD).",
"com_nav_info_code_artifacts": "Iespējo eksperimentāla koda artefaktu rādīšanu blakus sarunai",
"com_nav_info_code_artifacts_agent": "Iespējo koda artefaktu izmantošanu šim aģentam. Pēc noklusējuma tiek pievienotas papildu instrukcijas, kas attiecas uz artefaktu izmantošanu, ja vien nav iespējots \"Pielāgots uzvednes režīms\".",
@@ -534,7 +534,7 @@
"com_nav_latex_parsing": "LaTeX parsēšana ziņās (var ietekmēt veiktspēju)",
"com_nav_log_out": "Izrakstīties",
"com_nav_long_audio_warning": "Garāku tekstu apstrāde prasīs ilgāku laiku.",
"com_nav_maximize_chat_space": "Maksimāli izmantot sarunas telpas izmērus",
"com_nav_maximize_chat_space": "Maksimāli izmantot sarunu telpas izmērus",
"com_nav_mcp_configure_server": "Konfigurēt {{0}}",
"com_nav_mcp_status_connecting": "{{0}} - Savienojas",
"com_nav_mcp_vars_update_error": "Kļūda atjauninot MCP pielāgotos lietotāja parametrus: {{0}}",
@@ -554,7 +554,7 @@
"com_nav_profile_picture": "Profila attēls",
"com_nav_save_badges_state": "Saglabāt nozīmīšu stāvokli",
"com_nav_save_drafts": "Saglabāt melnrakstus lokāli",
"com_nav_scroll_button": "Pāriet uz pēdējo ierakstu poga",
"com_nav_scroll_button": "Rādīt pogu: pāriet uz pēdējo ierakstu",
"com_nav_search_placeholder": "Meklēt ziņas",
"com_nav_send_message": "Sūtīt ziņu",
"com_nav_setting_account": "Konts",
@@ -568,7 +568,7 @@
"com_nav_settings": "Iestatījumi",
"com_nav_shared_links": "Kopīgotās saites",
"com_nav_show_code": "Vienmēr rādīt kodu, izmantojot koda interpretētāju",
"com_nav_show_thinking": "Pēc noklusējuma atvērt domāšanas nolaižamos sarakstus",
"com_nav_show_thinking": "Pēc noklusējuma atvērt spriešanas sarakstu",
"com_nav_slash_command": "/-Komanda",
"com_nav_slash_command_description": "Ieslēgt komandu \"/\", lai atlasītu uzvedni izmantojot tastatūru",
"com_nav_speech_to_text": "Balss pārvēršana tekstā",
@@ -577,7 +577,7 @@
"com_nav_theme": "Tēma",
"com_nav_theme_dark": "Tumšs",
"com_nav_theme_light": "Gaišs",
"com_nav_theme_system": "Sistēma",
"com_nav_theme_system": "Sistēmas uzstādījums",
"com_nav_tool_dialog": "Asistenta rīki",
"com_nav_tool_dialog_agents": "Aģenta rīki",
"com_nav_tool_dialog_description": "Lai saglabātu rīku atlasi, ir jāsaglabā asistents.",
@@ -586,7 +586,7 @@
"com_nav_tool_search": "Meklēšanas rīki",
"com_nav_user": "LIETOTĀJS",
"com_nav_user_msg_markdown": "Atveidot lietotāja ziņas kā Markdown",
"com_nav_user_name_display": "Rādīt lietotājvārdu ziņās",
"com_nav_user_name_display": "Rādīt lietotājvārdu sarakstēs",
"com_nav_voice_select": "Balss",
"com_show_agent_settings": "Rādīt aģenta iestatījumus",
"com_show_completion_settings": "Rādīt pabeigšanas iestatījumus",
@@ -598,7 +598,7 @@
"com_sidepanel_hide_panel": "Slēpt paneli",
"com_sidepanel_manage_files": "Pārvaldīt failus",
"com_sidepanel_mcp_no_servers_with_vars": "Nav MCP serveru ar konfigurējamiem mainīgajiem.",
"com_sidepanel_parameters": "Parametri",
"com_sidepanel_parameters": "Modeļa parametri",
"com_sources_agent_file": "Avota dokuments",
"com_sources_agent_files": "Aģentu faili",
"com_sources_download_aria_label": "Lejupielādēt {{filename}}{{status}}",
@@ -650,7 +650,7 @@
"com_ui_agent_category_rd": "Pētniecība un attīstība",
"com_ui_agent_category_sales": "Pārdošana",
"com_ui_agent_category_selector_aria": "Aģenta kategorijas atlasītājs",
"com_ui_agent_chain": "Aģentu ķēde (aģentu maisījums)",
"com_ui_agent_chain": "Aģentu ķēde (Vairāki aģenti kopā sasaistīti)",
"com_ui_agent_chain_info": "Ļauj izveidot aģentu secību ķēdes. Katrs aģents var piekļūt iepriekšējo ķēdē esošo aģentu izvades datiem. Balstīts uz \"Aģentu sajaukuma\" arhitektūru, kurā aģenti izmanto iepriekšējos izvades datus kā palīginformāciju.",
"com_ui_agent_chain_max": "Jūs esat sasniedzis maksimālo skaitu {{0}} aģentu.",
"com_ui_agent_delete_error": "Dzēšot aģentu, radās kļūda.",
@@ -730,7 +730,7 @@
"com_ui_bearer": "Nesējs",
"com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?",
"com_ui_bookmarks": "Grāmatzīmes",
"com_ui_bookmarks_add": "Pievienot grāmatzīmes",
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
"com_ui_bookmarks_add_to_conversation": "Pievienot pašreizējai sarunai",
"com_ui_bookmarks_count": "Grāfs",
"com_ui_bookmarks_create_error": "Veidojot grāmatzīmi, radās kļūda",
@@ -766,7 +766,7 @@
"com_ui_command_placeholder": "Pēc izvēles: Ja tiks izmantota komanda uzvednei vai nosaukums, lūdzu ievadiet",
"com_ui_command_usage_placeholder": "Atlasiet uzvedni pēc komandas vai nosaukuma",
"com_ui_complete_setup": "Pabeigt iestatīšanu",
"com_ui_concise": "Īss",
"com_ui_concise": "Kodolīgs",
"com_ui_configure_mcp_variables_for": "Uzstādīt parametrus {{0}}",
"com_ui_confirm": "Apstiprināt",
"com_ui_confirm_action": "Apstiprināt darbību",
@@ -807,8 +807,8 @@
"com_ui_date_may": "Maijs",
"com_ui_date_november": "Novembris",
"com_ui_date_october": "Oktobris",
"com_ui_date_previous_30_days": "Iepriekšējās 30 dienas",
"com_ui_date_previous_7_days": "Iepriekšējās 7 dienas",
"com_ui_date_previous_30_days": "Pēdējās 30 dienas",
"com_ui_date_previous_7_days": "Pēdējās 7 dienas",
"com_ui_date_september": "Septembris",
"com_ui_date_today": "Šodien",
"com_ui_date_yesterday": "Vakar",
@@ -848,7 +848,7 @@
"com_ui_download_backup": "Lejupielādēt rezerves kodus",
"com_ui_download_backup_tooltip": "Pirms turpināt, lejupielādējiet rezerves kodus. Tie būs nepieciešami, lai atgūtu piekļuvi, ja pazaudēsiet autentifikatora ierīci.",
"com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir izdzēsts.",
"com_ui_drag_drop": "Nav rezultātu",
"com_ui_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai",
"com_ui_dropdown_variables": "Nolaižamās izvēlnes mainīgie:",
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}` (mainīgā_nosakums:opcija1|opcija2|opcija3)",
"com_ui_duplicate": "Dublicēt",
@@ -877,9 +877,9 @@
"com_ui_export_convo_modal": "Eksportēt sarunas modālo logu",
"com_ui_feedback_more": "Vairāk...",
"com_ui_feedback_more_information": "Sniegt papildu atsauksmes",
"com_ui_feedback_negative": "Nepieciešami uzlabojumi",
"com_ui_feedback_negative": "Atbildei nepieciešami uzlabojumi",
"com_ui_feedback_placeholder": "Lūdzu, sniedziet šeit jebkādas papildu atsauksmes.",
"com_ui_feedback_positive": "Man tas patīk",
"com_ui_feedback_positive": "Lieliska atbilde",
"com_ui_feedback_tag_accurate_reliable": "Precīzs un uzticams",
"com_ui_feedback_tag_attention_to_detail": "Uzmanība detaļām",
"com_ui_feedback_tag_bad_style": "Slikts stils vai tonis",
@@ -1046,6 +1046,7 @@
"com_ui_oauth_error_missing_code": "Trūkst autorizācijas koda. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_missing_state": "Trūkst stāvokļa parametrs. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_title": "Autentifikācija neizdevās",
"com_ui_oauth_revoke": "Atsaukt",
"com_ui_oauth_success_description": "Jūsu autentifikācija bija veiksmīga. Šis logs aizvērsies pēc",
"com_ui_oauth_success_title": "Autentifikācija veiksmīga",
"com_ui_of": "no",
@@ -1083,8 +1084,8 @@
"com_ui_quality": "Kvalitāte",
"com_ui_read_aloud": "Lasīt skaļi",
"com_ui_redirecting_to_provider": "Pārvirzu uz {{0}}, lūdzu, uzgaidiet...",
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām",
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz jūsu saglabātajām atmiņām un izmantot tās atbildot",
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām par lietotāju",
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz saglabātajām atmiņām par lietotāju un izmantot tās atbildot",
"com_ui_refresh": "Atsvaidzināt",
"com_ui_refresh_link": "Atsvaidzināt saiti",
"com_ui_regenerate": "Atjaunot",
@@ -1119,7 +1120,7 @@
"com_ui_role_viewer": "Skatītājs",
"com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt",
"com_ui_roleplay": "Lomu spēle",
"com_ui_run_code": "Palaišanas kods",
"com_ui_run_code": "Palaist kodu",
"com_ui_run_code_error": "Radās kļūda, izpildot kodu",
"com_ui_save": "Saglabāt",
"com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?",
@@ -1187,7 +1188,7 @@
"com_ui_support_contact": "Atbalsta kontaktinformācija",
"com_ui_support_contact_email": "E-pasts",
"com_ui_support_contact_email_invalid": "Lūdzu, ievadiet derīgu e-pasta adresi",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.com",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.lv",
"com_ui_support_contact_name": "Vārds",
"com_ui_support_contact_name_min_length": "Vārdam jābūt vismaz {{minLength}} rakstu zīmēm",
"com_ui_support_contact_name_placeholder": "Atbalsta kontaktpersonas vārds",
@@ -1196,7 +1197,7 @@
"com_ui_terms_and_conditions": "Noteikumi un nosacījumi",
"com_ui_terms_of_service": "Pakalpojumu sniegšanas noteikumi",
"com_ui_thinking": "Domā...",
"com_ui_thoughts": "Domas",
"com_ui_thoughts": "Spriešana",
"com_ui_token": "tokens",
"com_ui_token_exchange_method": "Tokenu apmaiņas metode",
"com_ui_token_url": "Tokena URL",
@@ -1220,19 +1221,19 @@
"com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP",
"com_ui_upload": "Augšupielādēt",
"com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts",
"com_ui_upload_code_files": "Augšupielādēt koda interpretētājam",
"com_ui_upload_code_files": "Augšupielādēt failu koda interpretētājam",
"com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.",
"com_ui_upload_error": "Augšupielādējot failu, radās kļūda.",
"com_ui_upload_file_context": "Augšupielādēt failu kontekstā",
"com_ui_upload_file_search": "Augšupielādēt failu meklēšanai",
"com_ui_upload_file_context": "Augšupielādēt failu kontekstu",
"com_ui_upload_file_search": "Augšupielādēt failu informācijas iegūšanai no tā",
"com_ui_upload_files": "Augšupielādēt failus",
"com_ui_upload_image": "Augšupielādēt attēlu",
"com_ui_upload_image_input": "Augšupielādēt attēlu",
"com_ui_upload_image": "Augšupielādēt failu kā attēlu",
"com_ui_upload_image_input": "Augšupielādēt failu kā attēlu",
"com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.",
"com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB",
"com_ui_upload_ocr_text": "Augšupielādēt kā tekstu",
"com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu",
"com_ui_upload_success": "Fails veiksmīgi augšupielādēts",
"com_ui_upload_type": "Atlasiet augšupielādes veidu",
"com_ui_upload_type": "Izvēlieties augšupielādes veidu",
"com_ui_usage": "Izmantošana",
"com_ui_use_2fa_code": "Izmantojiet 2FA kodu",
"com_ui_use_backup_code": "Izmantojiet rezerves kodu",
@@ -1253,6 +1254,7 @@
"com_ui_web_search_cohere_key": "Ievadiet Cohere API atslēgu",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (pēc izvēles)",
"com_ui_web_search_jina_key": "Ievadiet Jina API atslēgu",
"com_ui_web_search_jina_url": "Jina API URL (pēc izvēles)",
"com_ui_web_search_processing": "Rezultātu apstrāde",
"com_ui_web_search_provider": "Meklēšanas nodrošinātājs",
"com_ui_web_search_provider_searxng": "SearXNG",
@@ -1264,6 +1266,7 @@
"com_ui_web_search_reranker_cohere_key": "Iegūstiet savu Cohere API atslēgu",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "Iegūstiet savu Jina API atslēgu",
"com_ui_web_search_reranker_jina_url_help": "Uzzināt par Jina Rerank API",
"com_ui_web_search_scraper": "Scraper",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "Iegūstiet savu Firecrawl API atslēgu",

View File

@@ -4,7 +4,7 @@
"com_a11y_ai_composing": "De AI is nog bezig met het formuleren van een antwoord.",
"com_a11y_end": "De AI is klaar met het antwoord.",
"com_a11y_start": "De AI is begonnen met antwoorden.",
"com_agents_all": "Alle Agents\n\n",
"com_agents_all": "Alle Agents\n",
"com_agents_by_librechat": "door LibreChat",
"com_agents_category_empty": "Geen agents gevonden in de {{category}} categorie",
"com_agents_category_tab_label": "{{category}} categorie, {{position}} of {{total}}",
@@ -215,7 +215,7 @@
"com_endpoint_openai_max": "Het max. aantal tokens dat kan worden gegenereerd. De totale lengte van invoer-tokens en gegenereerde tokens is beperkt door de contextlengte van het model.",
"com_endpoint_openai_pres": "Getal tussen -2,0 en 2,0. Positieve waarden straffen nieuwe tokens op basis van of ze al voorkomen in de tekst tot nu toe, waardoor de kans dat het model over nieuwe onderwerpen praat toeneemt.",
"com_endpoint_openai_prompt_prefix_placeholder": "Stel aangepaste instructies in om op te nemen in Systeembericht. Standaard: geen",
"com_endpoint_openai_reasoning_effort": "Alleen voor redeneringsmodellen: beperkt de inspanning voor redeneren. Het verminderen van de redeneringsinspanning kan leiden tot snellere antwoorden en minder tokens die worden gebruikt voor redeneren in een antwoord. 'Minimaal' produceert zeer weinig redeneringstokens voor de snelste tijd tot het eerste token, vooral geschikt voor coderen en instructies volgen.",
"com_endpoint_openai_reasoning_effort": "Alleen redeneermodellen: beperkt de inspanning voor redeneren. Vermindering van redeneerinspanning kan resulteren in snellere reacties en minder tokens gebruikt voor redeneren in een reactie. Minimaal' produceert zeer weinig redeneertokens voor de snelste tijd-tot-eerste-tokens, vooral geschikt voor codering en het volgen van instructies.",
"com_endpoint_openai_temp": "Hogere waarden = meer willekeurig, terwijl lagere waarden = meer gericht en deterministisch. We raden aan dit of Top P te wijzigen, maar niet beide.",
"com_endpoint_openai_topp": "Een alternatief voor sampling met temperatuur, genaamd nucleus sampling, waarbij het model de resultaten van de tokens met de top_p waarschijnlijkheidsmassa in overweging neemt. Dus 0,1 betekent dat alleen de tokens die de bovenste 10% waarschijnlijkheidsmassa omvatten, in overweging worden genomen. We raden aan dit of temperatuur te wijzigen, maar niet beide.",
"com_endpoint_output": "Uitvoer",

View File

@@ -848,7 +848,7 @@
"com_ui_download_backup": "下载备份代码",
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
"com_ui_drag_drop": "这里需要放点东西,当前是空的",
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
"com_ui_dropdown_variables": "下拉变量:",
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "复制",
@@ -1044,6 +1044,7 @@
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
"com_ui_oauth_error_title": "认证失败",
"com_ui_oauth_revoke": "撤销",
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
"com_ui_oauth_success_title": "认证成功",
"com_ui_of": "/",
@@ -1252,6 +1253,7 @@
"com_ui_web_search_cohere_key": "输入 Cohere API Key",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL可选",
"com_ui_web_search_jina_key": "输入 Jina API Key",
"com_ui_web_search_jina_url": "Jina API URL可选",
"com_ui_web_search_processing": "正在处理结果",
"com_ui_web_search_provider": "搜索提供商",
"com_ui_web_search_provider_searxng": "SearXNG",
@@ -1263,6 +1265,7 @@
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
"com_ui_web_search_reranker_jina_url_help": "了解 Jina Rerank API",
"com_ui_web_search_scraper": "抓取器",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",

View File

@@ -6,8 +6,21 @@
"com_a11y_start": "AI 已開始回覆。",
"com_agents_agent_card_label": "{{name}} agent。{{description}}",
"com_agents_all": "全部 Agent",
"com_agents_all_category": "全部",
"com_agents_all_description": "瀏覽所有類別中的共用 agent",
"com_agents_by_librechat": "由 LibreChat 提供",
"com_agents_category_aftersales": "售後",
"com_agents_category_aftersales_description": "售後支援、維護與客戶服務 agent",
"com_agents_category_empty": "在 {{category}} 類別中找不到 agent",
"com_agents_category_finance": "財務",
"com_agents_category_finance_description": "財務分析、預算與會計 agent",
"com_agents_category_general": "通用",
"com_agents_category_general_description": "通用型 agent處理常見任務與詢問",
"com_agents_category_hr": "人資",
"com_agents_category_hr_description": "人力資源流程、政策與員工支援 agent",
"com_agents_category_it": "IT",
"com_agents_category_it_description": "IT 支援、技術排障與系統管理 agent",
"com_agents_category_rd": "研發",
"com_agents_category_tab_label": "{{category}} 類別,{{position}} / {{total}}",
"com_agents_category_tabs_label": "Agent 類別",
"com_agents_clear_search": "清除搜尋",

View File

@@ -6,6 +6,7 @@ import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import type { TPreset } from 'librechat-data-provider';
import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
import { useNewConvo, useAppStartup, useAssistantListMap, useIdChangeEffect } from '~/hooks';
import { useGetModelCostsQuery } from 'librechat-data-provider/react-query';
import { getDefaultModelSpec, getModelSpecPreset, logger } from '~/utils';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
@@ -44,6 +45,10 @@ export default function ChatRoute() {
const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated });
const assistantListMap = useAssistantListMap();
const modelCostsQuery = useGetModelCostsQuery(initialConvoQuery.data?.modelHistory || [], {
enabled: !!initialConvoQuery.data?.modelHistory?.length,
});
const isTemporaryChat = conversation && conversation.expiredAt ? true : false;
useEffect(() => {
@@ -148,7 +153,7 @@ export default function ChatRoute() {
return (
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
<ChatView index={index} />
<ChatView index={index} modelCosts={modelCostsQuery.data} />
</ToolCallsMapProvider>
);
}

View File

@@ -8,6 +8,7 @@ import {
TwoFactorScreen,
RequestPasswordReset,
} from '~/components/Auth';
import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
import AgentMarketplace from '~/components/Agents/Marketplace';
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext';
@@ -112,11 +113,19 @@ export const router = createBrowserRouter(
},
{
path: 'agents',
element: <AgentMarketplace />,
element: (
<MarketplaceProvider>
<AgentMarketplace />
</MarketplaceProvider>
),
},
{
path: 'agents/:category',
element: <AgentMarketplace />,
element: (
<MarketplaceProvider>
<AgentMarketplace />
</MarketplaceProvider>
),
},
],
},

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