Compare commits

..

25 Commits

Author SHA1 Message Date
Danny Avila
1e53ffa7ea v0.8.1-rc1 (#10316)
*  v0.8.1-rc1

* chore: Update CONFIG_VERSION to 1.3.1
2025-10-30 16:36:54 -04:00
github-actions[bot]
65281464fc 🌍 i18n: Update translation.json with latest translations (#10315)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 16:23:37 -04:00
Peter
658921af88 🔗 fix: Provided Azure Base URL Construction for Responses API (#10289)
* fix: response api works with azure base url configured

* add unit test

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-10-30 14:57:03 -04:00
Sean McGrath
ce6456c39f 🎨 fix: Update artifacts Tailwind to official CDN (#10301)
Co-authored-by: Sean McGrath <sean.mcgrath@holmesgroup.com>
2025-10-30 14:49:00 -04:00
Danny Avila
d904b281f1 🦙 fix: Ollama Custom Headers (#10314)
* 🦙 fix: Ollama Custom Headers

* chore: Correct import order for resolveHeaders in OllamaClient.js

* fix: Improve error logging for Ollama API model fetch failure

* ci: update Ollama model fetch tests

* ci: Add unit test for passing headers and user object to Ollama fetchModels
2025-10-30 14:48:10 -04:00
github-actions[bot]
5e35b7d09d 🌍 i18n: Update translation.json with latest translations (#10298)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 16:41:55 -04:00
Danny Avila
6adb425780 🔄 refactor: Max tokens handling in Agent Initialization (#10299)
* Refactored the logic for determining max output tokens in the agent initialization process.
* Changed variable names for clarity, updating from `maxTokens` to `maxOutputTokens` to better reflect their purpose.
* Adjusted calculations for `maxContextTokens` to use the new `maxOutputTokens` variable.
2025-10-29 16:41:27 -04:00
Danny Avila
e6aeec9f25 🎚️ feat: Reasoning Parameters for Custom Endpoints (#10297) 2025-10-29 13:41:35 -04:00
Danny Avila
861ef98d29 📫 refactor: OpenID Email Claim Fallback (#10296)
* 📫 refactor: Enhance OpenID email Fallback

* Updated email retrieval logic to use preferred_username or upn if email is not available.
* Adjusted logging and user data assignment to reflect the new email handling approach.
* Ensured email domain validation checks the correct email source.

* 🔄 refactor: Update Email Domain Validation Logic

* Modified `isEmailDomainAllowed` function to return true for falsy emails and missing domain restrictions.
* Added new test cases to cover scenarios with and without domain restrictions.
* Ensured proper validation when domain restrictions are present.
2025-10-29 12:57:43 -04:00
poornapragnyah
05c706137e ✂️ fix: Trim Reasoning Tags from Titles and Delete Button Visibility (#10285)
* fix: Sanitize LLM titles by stripping <think> tags and fix modal overflow

* chore: linting

* chore: Simplify title sanitization by removing unnecessary variable assignment and import order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-29 12:48:58 -04:00
github-actions[bot]
9fbc2afe40 🌍 i18n: Update translation.json with latest translations (#10282)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 12:38:43 -04:00
Danny Avila
8adef91cf5 🎚️ fix: Default Max Output Tokens for Claude 4+ Models (#10293) 2025-10-29 12:28:01 -04:00
Danny Avila
70ff6e94f2 🪢 feat: Add Langfuse Tracing Support (#10292)
* 📦 feat: `@librechat/agents` v2.4.87 for LangFuse Support

* 📦 chore: update @librechat/agents to v2.4.88 in package.json and package-lock.json

* 📦 chore: update @librechat/agents to v2.4.89

* feat: Add runName configuration to AgentClient and Memory agent for improved tracing
2025-10-29 12:23:09 -04:00
Danny Avila
0e05ff484f 🔄 refactor: OAI Image Edit Proxy, Speech Settings Handling, Import Query Data Usage (#10281)
* chore: correct startupConfig usage in ImportConversations component

* refactor: properly process configured speechToText and textToSpeech settings in getCustomConfigSpeech

* refactor: proxy configuration by utilizing HttpsProxyAgent for OpenAI Image Edits
2025-10-28 09:36:03 -04:00
github-actions[bot]
250209858a 🌍 i18n: Update translation.json with latest translations (#10274)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-28 09:00:19 -04:00
Daniel Paulus
9e77f835a6 🎛️ feat: Custom Environment Variable Support to RAG API Helm Chart (#10245)
* Possibility to add extra env values to the deployment

* Fix: Custom environment variables should be placed after the predefined environment variables
2025-10-28 08:37:04 -04:00
Danny Avila
7973cb42ef 🔃 refactor: Clear MCP only on Model Spec Selection without MCP Servers (#10273) 2025-10-27 20:33:51 -04:00
Dustin Healy
0446d0e190 fix: Address Accessibility Issues (#10260)
* chore: add i18n localization comment for AlwaysMakeProd component

* feat: enhance accessibility by adding aria-label and aria-labelledby to Switch component

* feat: add aria-labels for accessibility in Agent and Assistant avatar buttons

* fix: add switch aria-labels for accessibility in various components

* feat: add aria-labels and localization keys for accessibility in DataTable, DataTableColumnHeader, and OGDialogTemplate components

* chore: refactor out nested ternary

* feat: add aria-label to DataTable filter button for My Files modal

* feat: add aria-labels for Buttons and localization strings

* feat: add aria-labels to Checkboxes in Agent Builder

* feat: enhance accessibility by adding aria-label and aria-labelledby to Checkbox component

* feat: add aria-label to FileSearchCheckbox in Agent Builder

* feat: add aria-label to Prompts text input area

* feat: enhance accessibility by adding aria-label and aria-labelledby to TextAreaAutosize component

* feat: remove improper role: "list" prop from List in Conversations.tsx to enhance accessibility and stop aria rules conflicting within react-virtualized component

* feat: enhance accessibility by allowing tab navigation and adding ring highlights for conversation title editing accept/reject buttons

* feat: add aria-label to Copy Link button in the conversation share modal

* feat: add title to QR code svg in conversation share modal to  describe the image content

* feat: enhance accessibility by making Agent Avatar upload keyboard navigable and round out highlight border on focus

* feat: enhance accessibility by adding aria attributes around alerting users with screen readers to invalid email address inputs in the Agent Builder

* feat: add aria-labels to buttons in Advanced panel of Agent Builder

* feat: enhance accessibility by making FileUpload and Clear All buttons in PresetItems keyboard navigable

* feat: enchance accessiblity by indexing view and delete button aria-labels in shared links management modal to their specific chat titles

* feat: add border highlighting on focus for AnimatedSearchInput

* feat: add category description to aria-labels for prompts in ListCard

* feat: add proper scoping to rows and columns in table headers

* feat: add localized aria-labelling to EditTextPart's TextAreaAutosize component and base dynamic paramters panel components and their supporting translation keys

* feat: add localized aria-labels and aria-labelledBy to Checkbox components without them

* feat: add localized aria-labeledBy for endpoint settings Sliders

* feat: add localized aria-labels for TextareaAutosize components

* chore: remove unused i18n string

* feat: add localized aria-label for BookmarkForm Checkbox

* fix: add stopPropagation onKeyDown for Preview and Edit menu items in prompts that was causing the prompts to inadvertently be sent when triggered with keyboard navigation when Auto-send Prompts was toggled on

* fix: switch TableCell to TableHead for title cells according to harvard issue #789

* fix: add more descriptive localization key for file filter button in DataTable

* chore: remove self-explanatory code comment from RenameForm

* fix: remove stray bg-yellow highlight that was left in during debugging

* fix: add aria-label to model configurator panel back button

* fix: undo incorrect hoist of tool name split for aria-label and span in MCPInput

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-27 19:46:43 -04:00
Danny Avila
33d6b337bc 📛 feat: Chat Badges via Model Specs (#10272)
* refactor: remove `useChatContext` from `useSelectMention`, explicitly pass `conversation` object

* feat: ephemeral agents via model specs

* refactor: Sync Jotai state with ephemeral agent state, also when Ephemeral Agent has no MCP servers selected

* refactor: move `useUpdateEphemeralAgent` to store and clean up imports

* refactor: reorder imports and invalidate queries for mcpConnectionStatus in event handler

* refactor: replace useApplyModelSpecEffects with useApplyModelSpecAgents and update event handlers to use new agent template logic

* ci: update useMCPSelect test to verify mcpValues sync with empty ephemeralAgent.mcp
2025-10-27 19:46:30 -04:00
github-actions[bot]
64df54528d 🌍 i18n: Update translation.json with latest translations (#10259)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-27 19:45:37 -04:00
Max Sanna
d46dde4e01 👫 fix: Update Entra ID group retrieval to use getMemberGroups and add pagination support (#10199) 2025-10-26 21:58:29 -04:00
Federico Ruggi
13b784a3e6 🧼 fix: Sanitize MCP Server Selection Against Config (#10243)
* filter out unavailable servers

* bump render time

* Fix import path for useGetStartupConfig

* refactor: Change configuredServers to use Set for improved filtering of available MCPs

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-26 21:48:23 -04:00
Danny Avila
90e610ceda 🎪 refactor: Allow Last Model Spec Selection without Prioritizing (#10258)
* refactor: Default Model Spec Retrieval Logic, allowing last selected spec on new chat if last selection was a spec

* chore: Replace hardcoded 'new' conversation ID with Constants.NEW_CONVO for consistency

* chore: remove redundant condition for model spec preset selection in useNewConvo hook
2025-10-26 21:37:55 -04:00
github-actions[bot]
cbbbde3681 🌍 i18n: Update translation.json with latest translations (#10229)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-26 21:32:38 -04:00
Sebastien Bruel
05c9195197 🛠️ fix: Agent Tools Modal on First-Time Agent Creation (#10234) 2025-10-26 21:30:05 -04:00
167 changed files with 2498 additions and 687 deletions

233
AGENTS.md
View File

@@ -1,233 +0,0 @@
# LibreChat AGENTS.md
LibreChat is a multi-provider AI chat platform featuring agents, tools, and multimodal interactions. This file provides guidance for AI coding agents working on the codebase.
## Project Overview
LibreChat is a monorepo-based full-stack application providing a unified interface for multiple AI models and providers (OpenAI, Anthropic, Google Gemini, Azure, AWS Bedrock, Groq, Mistral, and more).
## Workspace Structure
LibreChat uses npm workspaces to organize code into distinct packages with clear responsibilities:
```
LibreChat/
├── api/ # Express.js backend (CJS, transitioning to TypeScript)
├── client/ # React/Vite frontend application
└── packages/
├── api/ # @librechat/api - Backend TypeScript package
├── client/ # @librechat/client - Frontend shared components
├── data-provider/ # librechat-data-provider - Shared frontend/backend
└── data-schemas/ # @librechat/data-schemas - Backend schemas & models
```
### Workspace Responsibilities
#### `/api` - Express.js Backend
- **Language**: CommonJS JavaScript (transitioning to TypeScript via shared packages)
- **Purpose**: Main Express.js server, routes, middleware, legacy services
- **Note**: Legacy workspace; **new backend logic should go in `packages/api` instead**
- **Uses**: `librechat-data-provider`, `@librechat/api`, `@librechat/data-schemas`
#### `/client` - React Frontend Application
- **Language**: TypeScript + React
- **Purpose**: Main web application UI
- **Uses**: `librechat-data-provider`, `@librechat/client`
#### `/packages/api` - `@librechat/api`
- **Language**: TypeScript (full support)
- **Purpose**: Backend-only package for all new backend logic
- **Used by**: `/api` workspace and potentially other backend projects
- **Key Modules**: Agents, MCP, tools, file handling, endpoints, authentication, caching, middleware
- **Critical**: **All new backend logic should be coded here first and foremost for full TypeScript support**
- **Depends on**: `@librechat/data-schemas`
#### `/packages/client` - `@librechat/client`
- **Language**: TypeScript + React
- **Purpose**: Reusable React components, hooks, and utilities
- **Used by**: `/client` workspace and other LibreChat team frontend repositories
- **Exports**: Common components, hooks, SVG icons, theme utilities, localization
#### `/packages/data-provider` - `librechat-data-provider`
- **Language**: TypeScript
- **Purpose**: **App-wide shared package** used by both frontend and backend
- **Scope**: Universal - used everywhere
- **Exports**: Data services, API endpoints, type definitions, utilities, React Query hooks
- **Note**: Foundation package that all other workspaces depend on
#### `/packages/data-schemas` - `@librechat/data-schemas`
- **Language**: TypeScript
- **Purpose**: Backend-only schemas, models, and data validation
- **Used by**: Backend workspaces only (`@librechat/api`, `/api`)
- **Exports**: Mongoose models, Zod schemas, database utilities, configuration schemas
## Build System & Dependencies
### Build Order
The build system has a specific dependency chain that must be followed:
```bash
# Full frontend build command:
npm run frontend
```
**Execution order:**
1. `librechat-data-provider` - Foundation package (all depend on this)
2. `@librechat/data-schemas` - Required by `@librechat/api`
3. `@librechat/api` - Backend TypeScript package
4. `@librechat/client` - Frontend shared components
5. `/client` - Final frontend compilation
### Individual Build Commands
Packages can be built separately as needed:
```bash
npm run build:data-provider # Build librechat-data-provider
npm run build:data-schemas # Build @librechat/data-schemas
npm run build:api # Build @librechat/api
npm run build:client-package # Build @librechat/client
npm run build:client # Build /client frontend app
npm run build:packages # Build all packages (excludes /client app)
```
**Note**: Not all packages need rebuilding for every change - only rebuild when files in that specific workspace are modified.
## Development Workflow
### Running Development Servers
```bash
# Backend
npm run backend:dev # Runs /api workspace with nodemon
# Frontend
npm run frontend:dev # Runs /client with Vite dev server
# Both (not recommended for active development)
npm run dev
```
### Where to Place New Code
#### Backend Logic
- **Primary location**: `/packages/api/src/` - Full TypeScript support
- **Legacy location**: `/api/` - Only for modifying existing CJS code
- **Strategy**: Prefer `@librechat/api` for all new features, utilities, and services
#### Frontend Components
- **Reusable components**: `/packages/client/src/components/`
- **App-specific components**: `/client/src/components/`
- **Strategy**: If it could be reused in other frontend projects, put it in `@librechat/client`
#### Shared Types & Utilities
- **Universal (frontend + backend)**: `/packages/data-provider/src/`
- **Backend-only**: `/packages/data-schemas/src/` or `/packages/api/src/`
- **Frontend-only**: `/packages/client/src/`
#### Database Models & Schemas
- **Always**: `/packages/data-schemas/src/models/` or `/packages/data-schemas/src/schema/`
## Technology Stack
### Backend
- **Runtime**: Node.js
- **Framework**: Express.js
- **Database**: MongoDB with Mongoose ODM
- **Authentication**: Passport.js (OAuth2, OpenID, LDAP, SAML, JWT)
- **AI Integration**: LangChain, direct SDK integrations
- **Streaming**: Server-Sent Events (SSE)
### Frontend
- **Framework**: React 18
- **Build Tool**: Vite
- **State Management**: Recoil
- **Styling**: TailwindCSS
- **UI Components**: Radix UI, Headless UI
- **Data Fetching**: TanStack Query (React Query)
- **HTTP Client**: Axios
### Package Manager
- **Primary**: npm (workspaces)
- **Alternative**: pnpm (compatible), bun (experimental scripts available)
## Configuration
### Environment Variables
- `.env` - Local development (never commit)
- `docker-compose.override.yml.example` - Example for Docker environment configuration
- Validate on server startup
### librechat.yaml
Main configuration for:
- AI provider endpoints
- Model configurations
- Agent and tool settings
- Authentication options
## Code Quality & Standards
### General Guidelines
- Use TypeScript for all new code (especially in `packages/`)
- Follow existing ESLint/Prettier configurations (formatting issues will be fixed manually)
- Keep functions focused and modular
- Handle errors appropriately with proper HTTP status codes
### File Naming
- React components: `PascalCase.tsx` (e.g., `ChatInterface.tsx`)
- Utilities: `camelCase.ts` (e.g., `formatMessage.ts`)
- Test files: `*.spec.ts` or `*.test.ts`
## Key Architectural Patterns
### Multi-Provider Pattern
Abstract AI provider implementations to support multiple services uniformly:
- Provider services implement common interfaces
- Handle streaming via SSE
- Support both completion and chat endpoints
### Agent System
- Built on LangChain
- Agent definitions in `packages/api/src/agents/`
- Tools in `packages/api/src/tools/`
- MCP (Model Context Protocol) support in `packages/api/src/mcp/`
### Data Layer
- `librechat-data-provider` provides unified data access
- React Query for frontend data fetching
- Backend services use Mongoose models from `@librechat/data-schemas`
## Testing
```bash
npm run test:api # Backend tests
npm run test:client # Frontend tests
npm run e2e # Playwright E2E tests
```
## Common Pitfalls
1. **Wrong workspace for backend code**: New backend logic belongs in `/packages/api`, not `/api`
- The `/api` workspace is still necessary as it's used to run the Express.js server, but all new logic should be written in TypeScript in `/packages/api` as much as possible. Existing logic should also be converted to TypeScript if possible.
2. **Build order matters**: Can't build `@librechat/api` before `@librechat/data-schemas`
3. **Unnecessary rebuilds**: Only rebuild packages when their source files change
4. **Import paths**: Use package names (`@librechat/api`) not relative paths across workspaces
5. **TypeScript vs CJS**: `/api` is still CJS; use `packages/api` for TypeScript
## Getting Help
- **Documentation**: https://docs.librechat.ai
- **Discord**: Active community support
- **GitHub Issues**: Bug reports and feature requests
- **Discussions**: General questions and ideas
---
**Remember**: The monorepo structure exists to enforce separation of concerns. Respect workspace boundaries and build dependencies. When in doubt about where code should live, consider:
- Is it backend logic? → `packages/api/src/`
- Is it a database model? → `packages/data-schemas/src/`
- Is it shared between frontend/backend? → `packages/data-provider/src/`
- Is it a reusable React component? → `packages/client/src/`
- Is it app-specific UI? → `client/src/`

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ const { z } = require('zod');
const axios = require('axios');
const { Ollama } = require('ollama');
const { sleep } = require('@librechat/agents');
const { logAxiosError } = require('@librechat/api');
const { resolveHeaders } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { deriveBaseURL } = require('~/utils');
@@ -44,6 +44,7 @@ class OllamaClient {
constructor(options = {}) {
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
this.headers = options.headers ?? {};
/** @type {Ollama} */
this.client = new Ollama({ host });
}
@@ -51,27 +52,32 @@ class OllamaClient {
/**
* Fetches Ollama models from the specified base API path.
* @param {string} baseURL
* @param {Object} [options] - Optional configuration
* @param {Partial<IUser>} [options.user] - User object for header resolution
* @param {Record<string, string>} [options.headers] - Headers to include in the request
* @returns {Promise<string[]>} The Ollama models.
* @throws {Error} Throws if the Ollama API request fails
*/
static async fetchModels(baseURL) {
let models = [];
static async fetchModels(baseURL, options = {}) {
if (!baseURL) {
return models;
}
try {
const ollamaEndpoint = deriveBaseURL(baseURL);
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
timeout: 5000,
});
models = response.data.models.map((tag) => tag.name);
return models;
} catch (error) {
const logMessage =
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
logAxiosError({ message: logMessage, error });
return [];
}
const ollamaEndpoint = deriveBaseURL(baseURL);
const resolvedHeaders = resolveHeaders({
headers: options.headers,
user: options.user,
});
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
headers: resolvedHeaders,
timeout: 5000,
});
const models = response.data.models.map((tag) => tag.name);
return models;
}
/**

View File

@@ -5,6 +5,7 @@ const FormData = require('form-data');
const { ProxyAgent } = require('undici');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, oaiToolkit } = require('@librechat/api');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -348,16 +349,7 @@ Error Message: ${error.message}`);
};
if (process.env.PROXY) {
try {
const url = new URL(process.env.PROXY);
axiosConfig.proxy = {
host: url.hostname.replace(/^\[|\]$/g, ''),
port: url.port ? parseInt(url.port, 10) : undefined,
protocol: url.protocol.replace(':', ''),
};
} catch (error) {
logger.error('Error parsing proxy URL:', error);
}
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {

View File

@@ -62,25 +62,37 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
const modelSpecs = req.config?.modelSpecs?.list;
/** @type {TModelSpec | null} */
let modelSpec = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
}
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
if (modelSpec?.mcpServers) {
for (const mcpServer of modelSpec.mcpServers) {
mcpServers.add(mcpServer);
}
}
/** @type {string[]} */
const tools = [];
if (ephemeralAgent?.execute_code === true) {
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
tools.push(Tools.execute_code);
}
if (ephemeralAgent?.file_search === true) {
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
tools.push(Tools.file_search);
}
if (ephemeralAgent?.web_search === true) {
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
@@ -122,17 +134,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
if (!agent_id) {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0",
"version": "v0.8.1-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.86",
"@librechat/agents": "^2.4.90",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View File

@@ -8,6 +8,7 @@ const {
Tokenizer,
checkAccess,
logAxiosError,
sanitizeTitle,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
@@ -775,6 +776,7 @@ class AgentClient extends BaseClient {
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = {
runName: 'AgentRun',
configurable: {
thread_id: this.conversationId,
last_agent_index: this.agentConfigs?.size ?? 0,
@@ -1233,6 +1235,10 @@ class AgentClient extends BaseClient {
handleLLMEnd,
},
],
configurable: {
thread_id: this.conversationId,
user_id: this.user ?? this.options.req.user?.id,
},
},
});
@@ -1270,7 +1276,7 @@ class AgentClient extends BaseClient {
);
});
return titleResult.title;
return sanitizeTitle(titleResult.title);
} catch (err) {
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
return;

View File

@@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({
}),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBe('Generated Title');
});
it('should sanitize the generated title by removing think blocks', async () => {
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
mockRun.generateTitle.mockResolvedValue({
title: titleWithThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should remove the <think> block and return only the clean title
expect(result).toBe('User Hi Greeting');
expect(result).not.toContain('<think>');
expect(result).not.toContain('</think>');
});
it('should return fallback title when sanitization results in empty string', async () => {
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
mockRun.generateTitle.mockResolvedValue({
title: titleOnlyThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return the fallback title since sanitization would result in empty string
expect(result).toBe('Untitled Conversation');
});
it('should handle errors gracefully and return undefined', async () => {
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));

View File

@@ -57,7 +57,7 @@ async function loadConfigModels(req) {
for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i];
const { models, name: configName, baseURL, apiKey } = endpoint;
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
const name = normalizeEndpointName(configName);
endpointsMap[name] = endpoint;
@@ -76,6 +76,8 @@ async function loadConfigModels(req) {
apiKey: API_KEY,
baseURL: BASE_URL,
user: req.user.id,
userObject: req.user,
headers: endpointHeaders,
direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery,
});

View File

@@ -134,10 +134,10 @@ const initializeAgent = async ({
});
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
const maxTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens,
modelOptions.maxTokens,
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
const maxOutputTokens = optionalChainWithEmptyCheck(
options.llmConfig?.maxOutputTokens,
options.llmConfig?.maxTokens,
0,
);
const agentMaxContextTokens = optionalChainWithEmptyCheck(
@@ -203,7 +203,7 @@ const initializeAgent = async ({
userMCPAuthMap,
toolContextMap,
useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
};
};

View File

@@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
const { loadAgent } = require('~/models/Agent');
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
const agentPromise = loadAgent({
req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
@@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint,
agent_id,
endpointType,
instructions,
model_parameters,
agent: agentPromise,
});

View File

@@ -1,4 +1,3 @@
const { Providers } = require('@librechat/agents');
const {
resolveHeaders,
isUserProvided,
@@ -143,39 +142,27 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
if (optionsOnly) {
const modelOptions = endpointOption?.model_parameters ?? {};
if (endpoint !== Providers.OLLAMA) {
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
if (clientOptions.reverseProxyUrl) {
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
delete clientOptions.reverseProxyUrl;
}
return {
useLegacyContent: true,
llmConfig: modelOptions,
};
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
return options;
}
const client = new OpenAIClient(apiKey, clientOptions);

View File

@@ -143,7 +143,7 @@ const initializeClient = async ({
modelOptions.model = modelName;
clientOptions = Object.assign({ modelOptions }, clientOptions);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions);
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null && serverless === true) {
options.useLegacyContent = true;
}

View File

@@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) {
settings.advancedMode = speechTab.advancedMode;
}
if (speechTab.speechToText) {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
if (speechTab.speechToText !== undefined) {
if (typeof speechTab.speechToText === 'boolean') {
settings.speechToText = speechTab.speechToText;
} else {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
}
}
}
}
if (speechTab.textToSpeech) {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
if (speechTab.textToSpeech !== undefined) {
if (typeof speechTab.textToSpeech === 'boolean') {
settings.textToSpeech = speechTab.textToSpeech;
} else {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
}
}
}
}

View File

@@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
@@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const response = await graphClient
.api('/me/getMemberGroups')
.post({ securityEnabledOnly: false });
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
const groupIds = Array.isArray(response?.value) ? response.value : [];
return [...new Set(groupIds.map((groupId) => String(groupId)))];
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
@@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => {
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allGroupIds = [];
let nextLink = '/me/ownedObjects/microsoft.graph.group';
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
while (nextLink) {
const response = await graphClient.api(nextLink).select('id').top(999).get();
const groups = response?.value || [];
allGroupIds.push(...groups.map((group) => group.id));
return (groupsResponse.value || []).map((group) => group.id);
nextLink = response['@odata.nextLink']
? response['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allGroupIds;
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
@@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
const allMembers = new Set();
let nextLink = `/groups/${groupId}/transitiveMembers`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
const members = membersResponse?.value || [];
members.forEach((member) => {
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
allMembers.add(member.id);
}
});
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
? membersResponse['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allMembers;
return Array.from(allMembers);
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];

View File

@@ -73,6 +73,7 @@ describe('GraphApiService', () => {
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
post: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
@@ -514,31 +515,33 @@ describe('GraphApiService', () => {
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
it('should fetch user groups using getMemberGroups endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
value: ['group-1', 'group-2'],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
expect(result).toEqual(['group-1', 'group-2']);
});
it('should deduplicate returned group ids', async () => {
mockGraphClient.post.mockResolvedValue({
value: ['group-1', 'group-2', 'group-1'],
});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
mockGraphClient.post.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -550,7 +553,7 @@ describe('GraphApiService', () => {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -558,7 +561,7 @@ describe('GraphApiService', () => {
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
mockGraphClient.post.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -566,6 +569,89 @@ describe('GraphApiService', () => {
});
});
describe('getUserOwnedEntraGroups', () => {
it('should fetch owned groups with pagination support', async () => {
const firstPage = {
value: [
{
id: 'owned-group-1',
},
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
};
const secondPage = {
value: [
{
id: 'owned-group-2',
},
],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
1,
'/me/ownedObjects/microsoft.graph.group',
);
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('getGroupMembers', () => {
it('should fetch transitive members and include only users', async () => {
const firstPage = {
value: [
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
};
const secondPage = {
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/groups/group-id/transitiveMembers?$skiptoken=abc',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(result).toEqual(['user-1', 'user-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -39,6 +39,8 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
* @async
*/
@@ -52,6 +54,8 @@ const fetchModels = async ({
userIdQuery = false,
createTokenConfig = true,
tokenKey,
headers,
userObject,
}) => {
let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
@@ -65,7 +69,13 @@ const fetchModels = async ({
}
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
return await OllamaClient.fetchModels(baseURL);
try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) {
const logMessage =
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
logAxiosError({ message: logMessage, error: ollamaError });
}
}
try {

View File

@@ -1,5 +1,5 @@
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, resolveHeaders } = require('@librechat/api');
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
const {
@@ -18,6 +18,8 @@ jest.mock('@librechat/api', () => {
processModelData: jest.fn((...args) => {
return originalUtils.processModelData(...args);
}),
logAxiosError: jest.fn(),
resolveHeaders: jest.fn((options) => options?.headers || {}),
};
});
@@ -277,12 +279,51 @@ describe('fetchModels with Ollama specific logic', () => {
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: {},
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
it('should pass headers and user object to Ollama fetchModels', async () => {
const customHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer custom-token',
};
const userObject = {
id: 'user789',
email: 'test@example.com',
};
resolveHeaders.mockReturnValueOnce(customHeaders);
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
baseURL: 'https://api.ollama.test.com',
name: 'ollama',
headers: customHeaders,
userObject,
});
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: customHeaders,
user: userObject,
});
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: customHeaders,
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
axios.get.mockResolvedValueOnce({
data: {
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
},
});
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
@@ -290,8 +331,13 @@ describe('fetchModels with Ollama specific logic', () => {
name: 'OllamaAPI',
});
expect(models).toEqual([]);
expect(logger.error).toHaveBeenCalled();
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
expect(logAxiosError).toHaveBeenCalledWith({
message:
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
error: expect.any(Error),
});
expect(axios.get).toHaveBeenCalledTimes(2);
});
it('should return an empty array if no baseURL is provided', async () => {

View File

@@ -357,16 +357,18 @@ async function setupOpenId() {
};
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const result = await findOpenIDUser({
findUser,
email: claims.email,
email: email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'openidStrategy',
@@ -433,7 +435,7 @@ async function setupOpenId() {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
email: email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
@@ -447,8 +449,8 @@ async function setupOpenId() {
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
if (userinfo.email && userinfo.email !== user.email) {
user.email = userinfo.email;
if (email && email !== user.email) {
user.email = email;
user.emailVerified = userinfo.email_verified || false;
}
}

View File

@@ -1,4 +1,4 @@
/** v0.8.0 */
/** v0.8.1-rc1 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',

View File

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

View File

@@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !isEphemeralAgent(agent_id),
});
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
enabled: !isEphemeralAgent(agent_id),
});
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const { data: mcpData } = useMCPToolsQuery({
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,

View File

@@ -58,6 +58,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>

View File

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

View File

@@ -129,7 +129,11 @@ const BookmarkForm = ({
</div>
<div className="mt-4 grid w-full items-center gap-2">
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
<Label
id="bookmark-description-label"
htmlFor="bookmark-description"
className="text-left text-sm font-medium"
>
{localize('com_ui_bookmarks_description')}
</Label>
<TextareaAutosize
@@ -147,6 +151,7 @@ const BookmarkForm = ({
className={cn(
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
)}
aria-labelledby="bookmark-description-label"
/>
</div>
{conversationId != null && conversationId && (
@@ -161,6 +166,7 @@ const BookmarkForm = ({
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
/>
)}
/>

View File

@@ -12,6 +12,7 @@ import {
import {
useTextarea,
useAutoSave,
useLocalize,
useRequiresKey,
useHandleKeyUp,
useQueryParams,
@@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
const localize = useLocalize();
const [isCollapsed, setIsCollapsed] = useState(false);
const [, setIsScrollable] = useState(false);
@@ -220,6 +222,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
@@ -230,6 +233,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
{showMentionPopover && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
@@ -277,6 +281,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(

View File

@@ -62,17 +62,28 @@ const FileUpload: React.FC<FileUploadProps> = ({
statusText = invalidText ?? localize('com_ui_upload_invalid');
}
const handleClick = () => {
const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
};
return (
<label
htmlFor={`file-upload-${id}`}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
<span className="flex text-xs">{statusText}</span>
<>
<button
type="button"
onClick={handleClick}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}
aria-label={statusText}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" aria-hidden="true" />
<span className="flex text-xs">{statusText}</span>
</button>
<input
id={`file-upload-${id}`}
value=""
@@ -80,8 +91,9 @@ const FileUpload: React.FC<FileUploadProps> = ({
className={cn('hidden', className)}
accept=".json"
onChange={handleFileChange}
tabIndex={-1}
/>
</label>
</>
);
};

View File

@@ -122,7 +122,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}>
<Button
variant="outline"
aria-label={localize('com_files_filter_by')}
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
>
<ListFilter className="size-3.5 sm:size-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { useCombobox } from '@librechat/client';
import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { MentionOption, ConvoGenerator } from '~/common';
import type { SetterOrUpdater } from 'recoil';
import useSelectMention from '~/hooks/Input/useSelectMention';
@@ -14,6 +15,7 @@ import MentionItem from './MentionItem';
const ROW_HEIGHT = 40;
export default function Mention({
conversation,
setShowMentionPopover,
newConversation,
textAreaRef,
@@ -21,6 +23,7 @@ export default function Mention({
placeholder = 'com_ui_mention',
includeAssistants = true,
}: {
conversation: TConversation | null;
setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
@@ -42,6 +45,7 @@ export default function Mention({
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
conversation,
assistantsMap,
endpointsConfig,
newConversation,

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { EModelEndpoint } from 'librechat-data-provider';
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
import { useChatContext } from '~/Providers/ChatContext';
interface ModelSelectorChatContextValue {
@@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue {
spec?: string | null;
agent_id?: string | null;
assistant_id?: string | null;
conversation: TConversation | null;
newConversation: ReturnType<typeof useChatContext>['newConversation'];
}
@@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN
spec: conversation?.spec,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
conversation,
newConversation,
}),
[
conversation?.endpoint,
conversation?.model,
conversation?.spec,
conversation?.agent_id,
conversation?.assistant_id,
newConversation,
],
[conversation, newConversation],
);
return (

View File

@@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { endpoint, model, spec, agent_id, assistant_id, newConversation } =
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
useModelSelectorChatContext();
const modelSpecs = useMemo(() => {
const specs = startupConfig?.modelSpecs?.list ?? [];
@@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
// presets,
modelSpecs,
conversation,
assistantsMap,
endpointsConfig,
newConversation,

View File

@@ -59,9 +59,10 @@ const PresetItems: FC<{
</label>
<Dialog>
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
<button
type="button"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
aria-label={localize('com_ui_clear') + ' ' + localize('com_ui_all')}
>
<svg
width="24"
@@ -70,11 +71,12 @@ const PresetItems: FC<{
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="mr-1 flex w-[22px] items-center"
aria-hidden="true"
>
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
</svg>
{localize('com_ui_clear')} {localize('com_ui_all')}
</label>
</button>
</DialogTrigger>
<DialogTemplate
showCloseButton={false}

View File

@@ -168,6 +168,7 @@ const EditMessage = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings,
)}
aria-label={localize('com_ui_message_input')}
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>

View File

@@ -170,6 +170,7 @@ const EditTextPart = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings,
)}
aria-label={localize('com_ui_editable_message')}
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>

View File

@@ -201,7 +201,6 @@ const Conversations: FC<ConversationsProps> = ({
overscanRowCount={10}
className="outline-none"
style={{ outline: 'none' }}
role="list"
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -82,7 +82,7 @@ export function DeleteConversationDialog({
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader>
<div>
<div className="w-full truncate">
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
</div>
<div className="flex justify-end gap-4 pt-4">

View File

@@ -77,7 +77,13 @@ export default function ShareButton({
<div className="relative items-center rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
<QRCodeSVG
value={sharedLink}
size={200}
marginSize={2}
className="rounded-2xl"
title={localize('com_ui_share_qr_code_description')}
/>
</div>
)}
@@ -87,6 +93,7 @@ export default function ShareButton({
<Button
size="sm"
variant="outline"
aria-label={localize('com_ui_copy_link')}
onClick={() => {
if (isCopying) {
return;

View File

@@ -34,6 +34,8 @@ const RenameForm: React.FC<RenameFormProps> = ({
case 'Enter':
onSubmit(titleInput);
break;
case 'Tab':
break;
}
};
@@ -50,22 +52,23 @@ const RenameForm: React.FC<RenameFormProps> = ({
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(titleInput)}
maxLength={100}
aria-label={localize('com_ui_new_conversation_title')}
/>
<div className="flex gap-1" role="toolbar">
<button
onClick={() => onCancel()}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_cancel')}
type="button"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
<button
onClick={() => onSubmit(titleInput)}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_save')}
type="button"
>
<Check className="h-4 w-4" aria-hidden="true" />
</button>

View File

@@ -151,6 +151,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
@@ -160,7 +161,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</Label>
<InputNumber
id="top-p-int"
@@ -189,6 +192,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
@@ -199,7 +203,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_frequency_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="freq-penalty-int"
@@ -228,6 +234,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
@@ -238,7 +245,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_presence_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="pres-penalty-int"
@@ -267,6 +276,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
@@ -306,6 +316,7 @@ export default function Settings({
onCheckedChange={(checked: boolean) => setResendFiles(checked)}
disabled={readonly}
className="flex"
aria-label={localize('com_endpoint_plug_resend_files')}
/>
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
</HoverCardTrigger>
@@ -323,6 +334,7 @@ export default function Settings({
max={2}
min={0}
step={1}
aria-label={localize('com_endpoint_plug_image_detail')}
/>
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
</HoverCardTrigger>

View File

@@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="temp-int"
@@ -82,6 +84,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeAgent}
disabled={readonly}
className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_use_functions')}
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} />
@@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeSkip}
disabled={readonly}
className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_skip_completion')}
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} />

View File

@@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.temperature.min}
step={google.temperature.step}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topP.min}
step={google.topP.step}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topK.min}
step={google.topK.step}
className="flex h-4 w-full"
aria-labelledby="top-k-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
@@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.maxOutputTokens.min}
step={google.maxOutputTokens.step}
className="flex h-4 w-full"
aria-labelledby="max-tokens-int"
/>
</HoverCardTrigger>
<OptionHover

View File

@@ -256,6 +256,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -296,6 +297,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@@ -337,6 +339,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} />
@@ -378,6 +381,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} />

View File

@@ -124,13 +124,15 @@ export default function ExportModal({
disabled={!exportOptionsSupport}
checked={includeOptions}
onCheckedChange={setIncludeOptions}
aria-labelledby="includeOptions-label"
/>
<label
id="includeOptions-label"
htmlFor="includeOptions"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportOptionsSupport
? localize('com_nav_enabled')
? localize('com_nav_export_include_endpoint_options')
: localize('com_nav_not_supported')}
</label>
</div>
@@ -146,13 +148,15 @@ export default function ExportModal({
disabled={!exportBranchesSupport}
checked={exportBranches}
onCheckedChange={setExportBranches}
aria-labelledby="exportBranches-label"
/>
<label
id="exportBranches-label"
htmlFor="exportBranches"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportBranchesSupport
? localize('com_nav_enabled')
? localize('com_nav_export_all_message_branches')
: localize('com_nav_not_supported')}
</label>
</div>
@@ -163,8 +167,14 @@ export default function ExportModal({
{localize('com_nav_export_recursive_or_sequential')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} />
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={setRecursive}
aria-labelledby="recursive-label"
/>
<label
id="recursive-label"
htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>

View File

@@ -30,6 +30,7 @@ export default function SaveBadgesState({
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="saveBadgesState"
aria-label={localize('com_nav_save_badges_state')}
/>
</div>
);

View File

@@ -30,6 +30,7 @@ export default function SaveDraft({
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="showThinking"
aria-label={localize('com_nav_show_thinking')}
/>
</div>
);

View File

@@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
function ImportConversations() {
const queryClient = useQueryClient();
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const handleSuccess = useCallback(() => {
@@ -53,7 +51,8 @@ function ImportConversations() {
const handleFileUpload = useCallback(
async (file: File) => {
try {
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize;
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
if (maxFileSize && file.size > maxFileSize) {
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
showToast({
@@ -76,7 +75,7 @@ function ImportConversations() {
});
}
},
[uploadFile, showToast, localize, startupConfig],
[uploadFile, showToast, localize, queryClient],
);
const handleFileChange = useCallback(

View File

@@ -13,7 +13,6 @@ import {
useMediaQuery,
OGDialogHeader,
OGDialogTitle,
TooltipAnchor,
DataTable,
Spinner,
Button,
@@ -246,37 +245,27 @@ export default function SharedLinks() {
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_view_source')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
title={localize('com_ui_view_source')}
>
<MessageSquare className="size-4" />
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
/>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
>
<MessageSquare className="size-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
>
<TrashIcon className="size-4" aria-hidden="true" />
</Button>
</div>
),
},

View File

@@ -53,6 +53,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
}
}}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
@@ -216,7 +217,12 @@ const AdminSettings = () => {
))}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting || isLoading} variant="submit">
<Button
type="submit"
disabled={isSubmitting || isLoading}
variant="submit"
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>

View File

@@ -28,7 +28,7 @@ export default function AlwaysMakeProd({
checked={alwaysMakeProd}
onCheckedChange={handleCheckedChange}
data-testid="alwaysMakeProd"
aria-label="Always make prompt production"
aria-label={localize('com_nav_always_make_prod')}
/>
<div>{localize('com_nav_always_make_prod')} </div>
</div>

View File

@@ -30,7 +30,7 @@ export default function AutoSendPrompt({
>
<div> {localize('com_nav_auto_send_prompts')} </div>
<Switch
aria-label="toggle-auto-send-prompts"
aria-label={localize('com_nav_auto_send_prompts')}
id="autoSendPrompts"
checked={autoSendPrompts}
onCheckedChange={handleCheckedChange}

View File

@@ -102,6 +102,9 @@ function ChatGroupItem({
e.stopPropagation();
setPreviewDialogOpen(true);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
>
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
@@ -116,6 +119,9 @@ function ChatGroupItem({
e.stopPropagation();
onEditClick(e);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
>
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span>

View File

@@ -151,6 +151,7 @@ const CreatePromptForm = ({
className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200"
minRows={6}
tabIndex={0}
aria-label={localize('com_ui_prompt_input_field')}
/>
<div
className={`mt-1 text-sm text-red-500 ${

View File

@@ -34,6 +34,7 @@ export default function List({
variant="outline"
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
onClick={() => navigate('/d/prompts/new')}
aria-label={localize('com_ui_create_prompt')}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_create_prompt')}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Label } from '@librechat/client';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useLocalize } from '~/hooks';
export default function ListCard({
category,
@@ -15,6 +16,7 @@ export default function ListCard({
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@@ -31,7 +33,7 @@ export default function ListCard({
tabIndex={0}
aria-labelledby={`card-title-${name}`}
aria-describedby={`card-snippet-${name}`}
aria-label={`Card for ${name}`}
aria-label={`${name} Prompt, ${category ? `${localize('com_ui_category')}: ${category}` : ''}`}
>
<div className="flex w-full justify-between gap-2">
<div className="flex flex-row gap-2">

View File

@@ -17,6 +17,7 @@ export default function NoPromptGroup() {
onClick={() => {
navigate('/d/prompts');
}}
aria-label={localize('com_ui_back_to_prompts')}
>
{localize('com_ui_back_to_prompts')}
</Button>

View File

@@ -193,6 +193,7 @@ export default function VariableForm({
)}
placeholder={field.config.variable}
maxRows={8}
aria-label={field.config.variable}
/>
);
}}
@@ -201,7 +202,7 @@ export default function VariableForm({
))}
</div>
<div className="flex justify-end">
<Button type="submit" variant="submit">
<Button type="submit" variant="submit" aria-label={localize('com_ui_submit')}>
{localize('com_ui_submit')}
</Button>
</div>

View File

@@ -118,6 +118,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
setIsEditing(false);
}
}}
aria-label={localize('com_ui_prompt_input')}
/>
) : (
<div

View File

@@ -370,7 +370,11 @@ export default function GenericGrantAccessDialog({
<div className="flex gap-2">
<PeoplePickerAdminSettings />
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
<Button
variant="outline"
onClick={handleCancel}
aria-label={localize('com_ui_cancel')}
>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
@@ -382,6 +386,7 @@ export default function GenericGrantAccessDialog({
(hasChanges && !hasAtLeastOneOwner)
}
className="min-w-[120px]"
aria-label={localize('com_ui_save_changes')}
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">

View File

@@ -60,6 +60,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
@@ -158,6 +159,7 @@ const PeoplePickerAdminSettings = () => {
<Button
variant={'outline'}
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}

View File

@@ -56,6 +56,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
@@ -152,6 +153,7 @@ const AdminSettings = () => {
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}

View File

@@ -17,6 +17,7 @@ const AdvancedButton: React.FC<AdvancedButtonProps> = ({ setActivePanel }) => {
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
onClick={() => setActivePanel(Panel.advanced)}
aria-label={localize('com_ui_advanced')}
>
<Settings2 className="h-4 w-4 cursor-pointer" aria-hidden="true" />
{localize('com_ui_advanced')}

View File

@@ -31,6 +31,7 @@ export default function AdvancedPanel() {
onClick={() => {
setActivePanel(Panel.builder);
}}
aria-label={localize('com_ui_back_to_builder')}
>
<div className="advanced-panel-content flex w-full items-center justify-center gap-2">
<ChevronLeft />

View File

@@ -146,6 +146,9 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
<button
className="rounded-xl p-1 transition hover:bg-surface-hover"
onClick={() => removeAgentAt(idx)}
aria-label={localize('com_ui_remove_agent_from_chain', {
0: getAgentDetails(agentId)?.name || localize('com_ui_agent'),
})}
>
<X size={18} className="text-text-secondary" />
</button>

View File

@@ -186,7 +186,11 @@ function Avatar({
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<div className="flex w-full items-center justify-center gap-4">
<Popover.Trigger asChild>
<button type="button" className="h-20 w-20">
<button
type="button"
className="f h-20 w-20 focus:rounded-full focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_upload_agent_avatar_label')}
>
{previewUrl ? <AgentAvatarRender url={previewUrl} progress={progress} /> : <NoImage />}
</button>
</Popover.Trigger>

View File

@@ -420,9 +420,16 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
type="text"
placeholder={localize('com_ui_support_contact_name_placeholder')}
aria-label="Support contact name"
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'support-contact-name-error' : undefined}
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
<span
id="support-contact-name-error"
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
aria-live="polite"
>
{error.message}
</span>
)}
@@ -455,9 +462,16 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
type="email"
placeholder={localize('com_ui_support_contact_email_placeholder')}
aria-label="Support contact email"
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'support-contact-email-error' : undefined}
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
<span
id="support-contact-email-error"
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
aria-live="polite"
>
{error.message}
</span>
)}

View File

@@ -283,6 +283,13 @@ export default function AgentPanel() {
setCurrentAgentId(undefined);
}}
disabled={agentQuery.isInitialLoading}
aria-label={
localize('com_ui_create') +
' ' +
localize('com_ui_new') +
' ' +
localize('com_ui_agent')
}
>
<Plus className="mr-1 h-4 w-4" />
{localize('com_ui_create') +

View File

@@ -117,6 +117,7 @@ function SwitchItem({
className="ml-4"
data-testid={id}
disabled={disabled}
aria-label={label}
/>
</div>
</HoverCard>

View File

@@ -61,6 +61,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
disabled={runCodeIsEnabled ? false : !isToolAuthenticated}
aria-label={localize('com_ui_run_code')}
/>
)}
/>
@@ -81,7 +82,11 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
</button>
<div className="ml-2 flex gap-2">
{isUserProvided && (isToolAuthenticated || runCodeIsEnabled) && (
<button type="button" onClick={() => setIsDialogOpen(true)}>
<button
type="button"
onClick={() => setIsDialogOpen(true)}
aria-label={localize('com_ui_add_api_key')}
>
<KeyRoundIcon className="h-5 w-5 text-text-primary" />
</button>
)}

View File

@@ -104,6 +104,7 @@ export default function ApiKeyDialog({
<Button
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
aria-label={localize('com_ui_revoke')}
>
{localize('com_ui_revoke')}
</Button>

View File

@@ -32,6 +32,7 @@ function FileSearchCheckbox() {
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
aria-label={localize('com_agents_enable_file_search')}
/>
)}
/>

View File

@@ -104,15 +104,16 @@ export function AvatarMenu({
className="flex min-w-[100px] max-w-xs flex-col rounded-xl border border-gray-400 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-850 dark:text-white"
sideOffset={5}
>
<div
<button
type="button"
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
tabIndex={-1}
tabIndex={0}
data-orientation="vertical"
onClick={onItemClick}
>
{localize('com_ui_upload_image')}
</div>
</button>
{/* <Popover.Close
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"

View File

@@ -210,10 +210,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
<Checkbox
id="trust-checkbox"
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="trust-label"
/>
)}
/>
<Label htmlFor="trust" className="flex flex-col">
<Label id="trust-label" htmlFor="trust-checkbox" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
@@ -269,6 +274,10 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
checked={selectedTools.includes(tool)}
onCheckedChange={() => handleToolToggle(tool)}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
aria-label={tool
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')}
/>
<span className="text-token-text-primary">
{tool

View File

@@ -162,6 +162,12 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
}
}}
tabIndex={isExpanded ? 0 : -1}
aria-label={
selectedTools.length === serverInfo.tools?.length &&
selectedTools.length > 0
? localize('com_ui_deselect_all')
: localize('com_ui_select_all')
}
/>
</div>
@@ -252,6 +258,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
className={cn(
'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
)}
aria-label={subTool.metadata.name}
/>
<span className="text-token-text-primary select-none">
{subTool.metadata.name}

View File

@@ -102,6 +102,7 @@ export default function ModelPanel({
onClick={() => {
setActivePanel(Panel.builder);
}}
aria-label={localize('com_ui_back_to_builder')}
>
<div className="model-panel-content flex w-full items-center justify-center gap-2">
<ChevronLeft />

View File

@@ -69,6 +69,7 @@ export default function Action({
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
disabled={webSearchIsEnabled ? false : !isToolAuthenticated}
aria-label={localize('com_ui_web_search')}
/>
)}
/>

View File

@@ -250,7 +250,11 @@ export default function ApiKeyDialog({
}}
buttons={
isToolAuthenticated && (
<Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600">
<Button
onClick={onRevoke}
className="bg-red-500 text-white hover:bg-red-600"
aria-label={localize('com_ui_revoke')}
>
{localize('com_ui_revoke')}
</Button>
)

View File

@@ -16,6 +16,7 @@ const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
onClick={() => setActivePanel(Panel.version)}
aria-label={localize('com_ui_agent_version')}
>
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
{localize('com_ui_agent_version')}

View File

@@ -112,6 +112,7 @@ const BookmarkTable = () => {
variant="outline"
size="sm"
className="w-full gap-2 text-sm"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setOpen(!open)}
>
<BookmarkPlusIcon className="size-4" />

View File

@@ -213,7 +213,11 @@ function Avatar({
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<div className="flex w-full items-center justify-center gap-4">
<Popover.Trigger asChild>
<button type="button" className="h-20 w-20">
<button
type="button"
className="h-20 w-20"
aria-label={localize('com_ui_upload_avatar_label')}
>
{previewUrl ? <AssistantAvatar url={previewUrl} progress={progress} /> : <NoImage />}
</button>
</Popover.Trigger>

View File

@@ -31,6 +31,7 @@ export default function Code({ version }: { version: number | string }) {
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
aria-labelledby={Capabilities.code_interpreter}
/>
)}
/>
@@ -44,6 +45,7 @@ export default function Code({ version }: { version: number | string }) {
}
>
<label
id={Capabilities.code_interpreter}
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.code_interpreter}
>

View File

@@ -21,10 +21,12 @@ export default function ImageVision() {
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
aria-labelledby={Capabilities.image_vision}
/>
)}
/>
<label
id={Capabilities.image_vision}
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.image_vision}
onClick={() =>

View File

@@ -60,11 +60,13 @@ export default function Retrieval({
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
aria-labelledby={Capabilities.retrieval}
/>
)}
/>
<div className="flex items-center space-x-2">
<label
id={Capabilities.retrieval}
className={cn(
'form-check-label text-token-text-primary w-full select-none',
isDisabled ? 'cursor-no-drop opacity-50' : 'cursor-pointer',

View File

@@ -17,6 +17,7 @@ export const columns: ColumnDef<TFile | undefined>[] = [
variant="ghost"
className="hover:bg-surface-hover"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
aria-label={localize('com_ui_name')}
>
{localize('com_ui_name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
@@ -40,6 +41,7 @@ export const columns: ColumnDef<TFile | undefined>[] = [
variant="ghost"
className="hover:bg-surface-hover"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
aria-label={localize('com_ui_date')}
>
{localize('com_ui_date')}
<ArrowUpDown className="ml-2 h-4 w-4" />

View File

@@ -127,7 +127,12 @@ function MCPPanelContent() {
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
<Button variant="outline" onClick={handleGoBackToList} size="sm">
<Button
variant="outline"
onClick={handleGoBackToList}
size="sm"
aria-label={localize('com_ui_back')}
>
<ChevronLeft className="mr-1 h-4 w-4" />
{localize('com_ui_back')}
</Button>
@@ -166,6 +171,7 @@ function MCPPanelContent() {
size="sm"
variant="destructive"
onClick={() => handleConfigRevoke(selectedServerNameForEditing)}
aria-label={localize('com_ui_oauth_revoke')}
>
<Trash2 className="h-4 w-4" />
{localize('com_ui_oauth_revoke')}
@@ -188,6 +194,7 @@ function MCPPanelContent() {
variant="outline"
className="flex-1 justify-start dark:hover:bg-gray-700"
onClick={() => handleServerClickToEdit(server.serverName)}
aria-label={localize('com_ui_edit') + ' ' + server.serverName}
>
<div className="flex items-center gap-2">
<span>{server.serverName}</span>

View File

@@ -39,6 +39,7 @@ const LabelController: React.FC<LabelControllerProps> = ({ control, memoryPerm,
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
@@ -141,6 +142,7 @@ const AdminSettings = () => {
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}

View File

@@ -147,6 +147,7 @@ export default function MemoryCreateDialog({
onClick={handleSave}
disabled={isLoading || !key.trim() || !value.trim()}
className="text-white"
aria-label={localize('com_ui_create_memory')}
>
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_create')}
</Button>

View File

@@ -192,6 +192,7 @@ export default function MemoryEditDialog({
type="button"
variant="submit"
onClick={handleSave}
aria-label={localize('com_ui_save')}
disabled={isLoading || !key.trim() || !value.trim()}
className="text-white"
>

View File

@@ -305,7 +305,11 @@ export default function MemoryViewer() {
<div className="flex w-full justify-end">
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<OGDialogTrigger asChild>
<Button variant="outline" className="w-full bg-transparent">
<Button
variant="outline"
className="w-full bg-transparent"
aria-label={localize('com_ui_create_memory')}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_create_memory')}
</Button>

View File

@@ -76,6 +76,7 @@ function DynamicCheckbox({
checked={selectedValue}
onCheckedChange={handleCheckedChange}
className="mt-[2px] focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
aria-label={localize(label as TranslationKeys)}
/>
</div>
</HoverCardTrigger>

View File

@@ -179,6 +179,7 @@ function DynamicSlider({
min={range ? range.min : 0}
step={range ? (range.step ?? 1) : 1}
controls={false}
aria-label={localize(label as TranslationKeys)}
className={cn(
defaultTextProps,
cn(
@@ -192,6 +193,7 @@ function DynamicSlider({
id={`${settingKey}-dynamic-setting-input`}
disabled={readonly}
value={getDisplayValue(selectedValue)}
aria-label={localize(label as TranslationKeys)}
onChange={() => ({})}
className={cn(
defaultTextProps,
@@ -214,6 +216,7 @@ function DynamicSlider({
onValueChange={(value) => handleValueChange(value[0])}
onDoubleClick={() => setInputValue(defaultValue as string | number)}
max={max}
aria-label={localize(label as TranslationKeys)}
min={range ? range.min : 0}
step={range ? (range.step ?? 1) : 1}
className="flex h-4 w-full"

View File

@@ -67,6 +67,9 @@ function DynamicSwitch({
onCheckedChange={handleCheckedChange}
disabled={readonly}
className="flex"
aria-label={
labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey
}
/>
</HoverCardTrigger>
{description && (

View File

@@ -75,6 +75,7 @@ function DynamicTextarea({
disabled={readonly}
value={inputValue ?? ''}
onChange={setInputValue}
aria-label={localize(label as TranslationKeys)}
placeholder={
placeholderCode
? (localize(placeholder as TranslationKeys) ?? placeholder)

View File

@@ -6,3 +6,4 @@ export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
export * from './useApplyModelSpecAgents';

View File

@@ -0,0 +1,95 @@
import { useCallback } from 'react';
import type { TStartupConfig, TSubmission } from 'librechat-data-provider';
import { useUpdateEphemeralAgent, useApplyNewAgentTemplate } from '~/store/agents';
import { getModelSpec, applyModelSpecEphemeralAgent } from '~/utils';
/**
* Hook that applies a model spec from a preset to an ephemeral agent.
* This is used when initializing a new conversation with a preset that has a spec.
*/
export function useApplyModelSpecEffects() {
const updateEphemeralAgent = useUpdateEphemeralAgent();
const applyModelSpecEffects = useCallback(
({
convoId,
specName,
startupConfig,
}: {
convoId: string | null;
specName?: string | null;
startupConfig?: TStartupConfig;
}) => {
if (specName == null || !specName) {
return;
}
const modelSpec = getModelSpec({
specName,
startupConfig,
});
applyModelSpecEphemeralAgent({
convoId,
modelSpec,
updateEphemeralAgent,
});
},
[updateEphemeralAgent],
);
return applyModelSpecEffects;
}
export function useApplyAgentTemplate() {
const applyAgentTemplate = useApplyNewAgentTemplate();
/**
* Helper function to apply agent template with model spec merged into ephemeral agent
*/
const applyAgentTemplateWithSpec = useCallback(
({
targetId,
sourceId,
ephemeralAgent,
specName,
startupConfig,
}: {
targetId: string;
sourceId?: TSubmission['conversation']['conversationId'] | null;
ephemeralAgent: TSubmission['ephemeralAgent'];
specName?: string | null;
startupConfig?: TStartupConfig;
}) => {
if (!specName) {
applyAgentTemplate(targetId, sourceId, ephemeralAgent);
return;
}
const modelSpec = getModelSpec({
specName,
startupConfig,
});
if (!modelSpec) {
applyAgentTemplate(targetId, sourceId, ephemeralAgent);
return;
}
// Merge model spec fields into ephemeral agent
const mergedAgent = {
...ephemeralAgent,
mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])],
web_search: ephemeralAgent?.web_search ?? modelSpec.webSearch ?? false,
file_search: ephemeralAgent?.file_search ?? modelSpec.fileSearch ?? false,
execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false,
};
// Deduplicate MCP servers
mergedAgent.mcp = [...new Set(mergedAgent.mcp)];
applyAgentTemplate(targetId, sourceId, mergedAgent);
},
[applyAgentTemplate],
);
return applyAgentTemplateWithSpec;
}

View File

@@ -1,8 +1,14 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants, dataService } from 'librechat-data-provider';
import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider';
import type {
TEndpointsConfig,
TStartupConfig,
TModelsConfig,
TConversation,
} from 'librechat-data-provider';
import {
getDefaultEndpoint,
clearMessagesCache,
@@ -10,15 +16,34 @@ import {
getEndpointField,
logger,
} from '~/utils';
import { useApplyModelSpecEffects } from '~/hooks/Agents';
import store from '~/store';
const useNavigateToConvo = (index = 0) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const clearAllConversations = store.useClearConvoState();
const applyModelSpecEffects = useApplyModelSpecEffects();
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`);
const { hasSetConversation, setConversation } = store.useCreateConversationAtom(index);
const { hasSetConversation, setConversation: setConvo } = store.useCreateConversationAtom(index);
const setConversation = useCallback(
(conversation: TConversation) => {
setConvo(conversation);
if (!conversation.spec) {
return;
}
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
applyModelSpecEffects({
startupConfig,
specName: conversation?.spec,
convoId: conversation.conversationId,
});
},
[setConvo, queryClient, applyModelSpecEffects],
);
const fetchFreshData = async (conversation?: Partial<TConversation>) => {
const conversationId = conversation?.conversationId;

View File

@@ -10,18 +10,19 @@ import type {
} from 'librechat-data-provider';
import type { MentionOption, ConvoGenerator } from '~/common';
import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils';
import { useChatContext } from '~/Providers';
import { useDefaultConvo } from '~/hooks';
import store from '~/store';
export default function useSelectMention({
presets,
modelSpecs,
conversation,
assistantsMap,
returnHandlers,
endpointsConfig,
newConversation,
returnHandlers,
}: {
conversation: TConversation | null;
presets?: TPreset[];
modelSpecs: TModelSpec[];
assistantsMap?: TAssistantsMap;
@@ -29,7 +30,6 @@ export default function useSelectMention({
endpointsConfig: TEndpointsConfig;
returnHandlers?: boolean;
}) {
const { conversation } = useChatContext();
const getDefaultConversation = useDefaultConvo();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);

View File

@@ -6,6 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { setTimestamp } from '~/utils/timestamps';
import { useMCPSelect } from '../useMCPSelect';
import * as dataProvider from '~/data-provider';
// Mock dependencies
jest.mock('~/utils/timestamps', () => ({
@@ -14,10 +15,21 @@ jest.mock('~/utils/timestamps', () => ({
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
const createWrapper = () => {
jest.mock('~/data-provider', () => ({
...jest.requireActual('~/data-provider'),
useGetStartupConfig: jest.fn(),
}));
const createWrapper = (mcpServers: string[] = []) => {
// Create a new Jotai store for each test to ensure clean state
const store = createStore();
// Mock the startup config
(dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({
data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) },
isLoading: false,
});
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<RecoilRoot>
<Provider store={store}>{children}</Provider>
@@ -65,7 +77,7 @@ describe('useMCPSelect', () => {
describe('State Updates', () => {
it('should update mcpValues when setMCPValues is called', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
wrapper: createWrapper(['value1', 'value2']),
});
const newValues = ['value1', 'value2'];
@@ -229,7 +241,7 @@ describe('useMCPSelect', () => {
const { result, rerender } = renderHook(
({ conversationId }) => useMCPSelect({ conversationId }),
{
wrapper: createWrapper(),
wrapper: createWrapper(['convo1-value', 'convo2-value']),
initialProps: { conversationId: 'convo1' },
},
);
@@ -271,7 +283,7 @@ describe('useMCPSelect', () => {
describe('Ephemeral Agent Synchronization', () => {
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
const wrapper = createWrapper();
const wrapper = createWrapper(['external-value1', 'external-value2']);
// Create a component that uses both hooks to ensure they share state
const TestComponent = () => {
@@ -298,9 +310,75 @@ describe('useMCPSelect', () => {
});
});
it('should filter out MCPs not in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2']);
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
act(() => {
result.current.setEphemeralAgent({
mcp: ['server1', 'removed-server', 'server2'],
});
});
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(['server1', 'server2']);
});
});
it('should clear all MCPs when none are in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2']);
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
act(() => {
result.current.setEphemeralAgent({
mcp: ['removed1', 'removed2', 'removed3'],
});
});
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual([]);
});
});
it('should keep all MCPs when all are in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2', 'server3']);
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
act(() => {
result.current.setEphemeralAgent({
mcp: ['server1', 'server2'],
});
});
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(['server1', 'server2']);
});
});
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
// Create a shared wrapper for both hooks
const wrapper = createWrapper();
const wrapper = createWrapper(['hook-value1', 'hook-value2']);
// Create a component that uses both the hook and accesses Recoil state
const TestComponent = () => {
@@ -326,7 +404,7 @@ describe('useMCPSelect', () => {
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
// Create a shared wrapper
const wrapper = createWrapper();
const wrapper = createWrapper(['initial-value']);
// Create a component that uses both hooks
const TestComponent = () => {
@@ -358,9 +436,44 @@ describe('useMCPSelect', () => {
expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']);
});
it('should handle ephemeralAgent with clear mcp value', async () => {
// Create a shared wrapper
const wrapper = createWrapper(['server1', 'server2']);
// Create a component that uses both hooks
const TestComponent = () => {
const mcpHook = useMCPSelect({});
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent };
};
const { result } = renderHook(() => TestComponent(), { wrapper });
// Set initial values
act(() => {
result.current.mcpHook.setMCPValues(['server1', 'server2']);
});
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual(['server1', 'server2']);
});
// Set ephemeralAgent with clear value
act(() => {
result.current.setEphemeralAgent({
mcp: [Constants.mcp_clear as string],
});
});
// mcpValues should be cleared
await waitFor(() => {
expect(result.current.mcpHook.mcpValues).toEqual([]);
});
});
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
// Additional test to ensure non-empty arrays DO sync
const wrapper = createWrapper();
const wrapper = createWrapper(['value1', 'value2', 'value3', 'value4', 'value5']);
const TestComponent = () => {
const mcpHook = useMCPSelect({});
@@ -401,7 +514,7 @@ describe('useMCPSelect', () => {
describe('Edge Cases', () => {
it('should handle undefined conversationId', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), {
wrapper: createWrapper(),
wrapper: createWrapper(['test']),
});
expect(result.current.mcpValues).toEqual([]);
@@ -422,11 +535,10 @@ describe('useMCPSelect', () => {
});
it('should handle very large arrays without performance issues', async () => {
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
});
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(largeArray),
});
const startTime = performance.now();
@@ -457,8 +569,9 @@ describe('useMCPSelect', () => {
describe('Memory Leak Prevention', () => {
it('should not leak memory on repeated updates', async () => {
const values = Array.from({ length: 100 }, (_, i) => `value-${i}`);
const { result } = renderHook(() => useMCPSelect({}), {
wrapper: createWrapper(),
wrapper: createWrapper(values),
});
// Perform many updates to test for memory leaks

View File

@@ -1,13 +1,18 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useAtom } from 'jotai';
import isEqual from 'lodash/isEqual';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
import { useGetStartupConfig } from '~/data-provider';
import { setTimestamp } from '~/utils/timestamps';
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
const key = conversationId ?? Constants.NEW_CONVO;
const { data: startupConfig } = useGetStartupConfig();
const configuredServers = useMemo(() => {
return new Set(Object.keys(startupConfig?.mcpServers ?? {}));
}, [startupConfig?.mcpServers]);
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
@@ -15,10 +20,15 @@ export function useMCPSelect({ conversationId }: { conversationId?: string | nul
// Sync Jotai state with ephemeral agent state
useEffect(() => {
if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) {
setMCPValuesRaw(ephemeralAgent.mcp);
const mcps = ephemeralAgent?.mcp ?? [];
if (mcps.length === 1 && mcps[0] === Constants.mcp_clear) {
setMCPValuesRaw([]);
} else if (mcps.length > 0) {
// Strip out servers that are not available in the startup config
const activeMcps = mcps.filter((mcp) => configuredServers.has(mcp));
setMCPValuesRaw(activeMcps);
}
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
}, [ephemeralAgent?.mcp, setMCPValuesRaw, configuredServers]);
useEffect(() => {
setEphemeralAgent((prev) => {

View File

@@ -1,8 +1,8 @@
import { v4 } from 'uuid';
import { useCallback, useRef } from 'react';
import { v4 } from 'uuid';
import { useSetRecoilState } from 'recoil';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import {
QueryKeys,
Constants,
@@ -13,7 +13,12 @@ import {
tConvoUpdateSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TMessage, TConversation, EventSubmission } from 'librechat-data-provider';
import type {
TMessage,
TConversation,
EventSubmission,
TStartupConfig,
} from 'librechat-data-provider';
import type { TResData, TFinalResData, ConvoGenerator } from '~/common';
import type { InfiniteData } from '@tanstack/react-query';
import type { TGenTitleMutation } from '~/data-provider';
@@ -31,11 +36,12 @@ import {
} from '~/utils';
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
import useContentHandler from '~/hooks/SSE/useContentHandler';
import store, { useApplyNewAgentTemplate } from '~/store';
import useStepHandler from '~/hooks/SSE/useStepHandler';
import { useApplyAgentTemplate } from '~/hooks/Agents';
import { useAuthContext } from '~/hooks/AuthContext';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
import { useLiveAnnouncer } from '~/Providers';
import store from '~/store';
type TSyncData = {
sync: boolean;
@@ -172,7 +178,7 @@ export default function useEventHandlers({
}: EventHandlerParams) {
const queryClient = useQueryClient();
const { announcePolite } = useLiveAnnouncer();
const applyAgentTemplate = useApplyNewAgentTemplate();
const applyAgentTemplate = useApplyAgentTemplate();
const setAbortScroll = useSetRecoilState(store.abortScroll);
const navigate = useNavigate();
const location = useLocation();
@@ -356,6 +362,7 @@ export default function useEventHandlers({
const createdHandler = useCallback(
(data: TResData, submission: EventSubmission) => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
const { messages, userMessage, isRegenerate = false, isTemporary = false } = submission;
const initialResponse = {
...submission.initialResponse,
@@ -411,11 +418,13 @@ export default function useEventHandlers({
}
if (conversationId) {
applyAgentTemplate(
conversationId,
submission.conversation.conversationId,
submission.ephemeralAgent,
);
applyAgentTemplate({
targetId: conversationId,
sourceId: submission.conversation?.conversationId,
ephemeralAgent: submission.ephemeralAgent,
specName: submission.conversation?.spec,
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
});
}
if (resetLatestMessage) {
@@ -566,11 +575,13 @@ export default function useEventHandlers({
});
if (conversation.conversationId && submission.ephemeralAgent) {
applyAgentTemplate(
conversation.conversationId,
submissionConvo.conversationId,
submission.ephemeralAgent,
);
applyAgentTemplate({
targetId: conversation.conversationId,
sourceId: submissionConvo.conversationId,
ephemeralAgent: submission.ephemeralAgent,
specName: submission.conversation?.spec,
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
});
}
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {

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