Merge branch 'main' into feat/Multitenant-login-OIDC

This commit is contained in:
Ruben Talstra 2025-05-22 10:49:49 +02:00 committed by GitHub
commit bba4184b2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 3565 additions and 954 deletions

View file

@ -565,9 +565,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
# users always get the latest version. Customize #
# only if you understand caching implications. #
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
# INDEX_HTML_PRAGMA=no-cache
# INDEX_HTML_EXPIRES=0
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
# INDEX_PRAGMA=no-cache
# INDEX_EXPIRES=0
# no-cache: Forces validation with server before using cached version
# no-store: Prevents storing the response entirely

View file

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

View file

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

7
.gitignore vendored
View file

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

View file

@ -5,23 +5,38 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### ✨ New Features
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
### 🌍 Internationalization
- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
### 🔧 Fixes
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
@ -67,7 +82,6 @@ Changes from v0.7.8-rc1 to v0.7.8.
---
## [v0.7.8-rc1] -
## [v0.7.8-rc1] -
Changes from v0.7.7 to v0.7.8-rc1.

View file

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

View file

@ -86,7 +86,7 @@
"mime": "^3.0.0",
"module-alias": "^2.2.3",
"mongoose": "^8.12.1",
"multer": "^1.4.5-lts.1",
"multer": "^2.0.0",
"nanoid": "^3.3.7",
"nodemailer": "^6.9.15",
"ollama": "^0.5.0",

View file

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

View file

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

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

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

View file

@ -74,6 +74,7 @@ router.get('/', async function (req, res) {
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: req.app.locals.interfaceConfig,
turnstile: req.app.locals.turnstileConfig,
modelSpecs: req.app.locals.modelSpecs,
balance: req.app.locals.balance,
sharedLinksEnabled,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -535,6 +535,7 @@ export type NewConversationParams = {
buildDefault?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
};
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,10 +50,12 @@ export default function AgentFooter({
return localize('com_ui_create');
};
const showButtons = activePanel === Panel.builder;
return (
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
<div className="mb-1 flex w-full flex-col gap-2">
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
{/* Context Button */}
<div className="flex items-center justify-end gap-2">
<DeleteButton
@ -63,13 +65,13 @@ export default function AgentFooter({
/>
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
hasAccessToShareAgents && (
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
{/* Submit Button */}
<button

View file

@ -220,7 +220,7 @@ export default function AgentPanel({
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
aria-label="Agent configuration form"
>
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
<div className="mt-2 flex w-full flex-wrap gap-2">
<div className="w-full">
<AgentSelect
createMutation={create}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import { useState, useMemo } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import { useState } from 'react';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
@ -14,7 +13,6 @@ function DynamicSwitch({
description = '',
columnSpan,
setOption,
optionType,
readonly = false,
showDefault = false,
labelCode = false,
@ -34,21 +32,10 @@ function DynamicSwitch({
preventDelayedUpdate: true,
});
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
const selectedValue = conversation?.[settingKey] ?? defaultValue;
const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setInputValue(checked);
setOption(settingKey)(checked);
};
@ -65,7 +52,7 @@ function DynamicSwitch({
htmlFor={`${settingKey}-dynamic-switch`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}:{' '}
@ -84,7 +71,11 @@ function DynamicSwitch({
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left}
/>
)}

View file

@ -1,10 +1,9 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui';
import { useChatContext, useToastContext } from '~/Providers';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps } from '~/utils';
import { cn } from '~/utils';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@ -15,7 +14,6 @@ function DynamicTags({
description = '',
columnSpan,
setOption,
optionType,
placeholder = '',
readonly = false,
showDefault = false,
@ -38,14 +36,10 @@ function DynamicTags({
const updateState = useCallback(
(update: string[]) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setTags(update);
return;
}
setTags(update);
setOption(settingKey)(update);
},
[optionType, setOption, settingKey],
[setOption, settingKey],
);
const onTagClick = useCallback(() => {
@ -54,18 +48,10 @@ function DynamicTags({
}
}, [inputRef]);
const currentTags: string[] | undefined = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return tags;
}
if (!conversation?.[settingKey]) {
return defaultValue ?? [];
}
return conversation[settingKey];
}, [conversation, defaultValue, optionType, settingKey, tags]);
const currentValue = conversation?.[settingKey];
const currentTags = useMemo(() => {
return currentValue ?? defaultValue ?? [];
}, [currentValue, defaultValue]);
const onTagRemove = useCallback(
(indexToRemove: number) => {
@ -75,7 +61,7 @@ function DynamicTags({
if (minTags != null && currentTags.length <= minTags) {
showToast({
message: localize('com_ui_min_tags',{ 0: minTags + '' }),
message: localize('com_ui_min_tags', { 0: minTags + '' }),
status: 'warning',
});
return;
@ -126,7 +112,7 @@ function DynamicTags({
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
(
@ -174,7 +160,11 @@ function DynamicTags({
}
}}
onChange={(e) => setTagText(e.target.value)}
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder}
placeholder={
placeholderCode
? (localize(placeholder as TranslationKeys) ?? placeholder)
: placeholder
}
className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')}
/>
</div>
@ -182,7 +172,11 @@ function DynamicTags({
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={descriptionSide as ESide}
/>
)}

View file

@ -2,7 +2,7 @@ import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import { cn, defaultTextProps } from '~/utils';
import { cn } from '~/utils';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@ -27,7 +27,7 @@ function DynamicTextarea({
const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
optionKey: settingKey,
initialValue:
optionType !== OptionTypes.Custom
? (conversation?.[settingKey] as string)

View file

@ -1,6 +1,12 @@
import { RotateCcw } from 'lucide-react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { excludedKeys, getSettingsKeys, tConvoUpdateSchema } from 'librechat-data-provider';
import {
excludedKeys,
getSettingsKeys,
tConvoUpdateSchema,
paramSettings,
SettingDefinition,
} from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider';
import { SaveAsPresetDialog } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks';
@ -8,7 +14,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, logger } from '~/utils';
import { componentMapping } from './components';
import { useChatContext } from '~/Providers';
import { settings } from './settings';
import keyBy from 'lodash/keyBy';
export default function Parameters() {
const localize = useLocalize();
@ -18,7 +24,9 @@ export default function Parameters() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [preset, setPreset] = useState<TPreset | null>(null);
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
const provider = conversation?.endpoint ?? '';
const model = conversation?.model ?? '';
const bedrockRegions = useMemo(() => {
return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? [];
@ -29,13 +37,17 @@ export default function Parameters() {
[conversation?.endpoint, endpointsConfig],
);
const parameters = useMemo(() => {
const [combinedKey, endpointKey] = getSettingsKeys(
endpointType ?? conversation?.endpoint ?? '',
conversation?.model ?? '',
const parameters = useMemo((): SettingDefinition[] => {
const customParams = endpointsConfig[provider]?.customParams ?? {};
const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model);
const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey;
const defaultParams = paramSettings[combinedKey] ?? paramSettings[overriddenEndpointKey] ?? [];
const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? [];
const overriddenParamsMap = keyBy(overriddenParams, 'key');
return defaultParams.map(
(param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param,
);
return settings[combinedKey] ?? settings[endpointKey];
}, [conversation, endpointType]);
}, [endpointType, endpointsConfig, model, provider]);
useEffect(() => {
if (!parameters) {

View file

@ -9,7 +9,6 @@ import { useGetEndpointsQuery } from '~/data-provider';
import NavToggle from '~/components/Nav/NavToggle';
import { cn, getEndpointField } from '~/utils';
import { useChatContext } from '~/Providers';
import Nav from './Nav';
const defaultMinSize = 20;

View file

@ -1,20 +1,67 @@
import { cn } from '~/utils/';
export default function Spinner({ className = 'm-auto', size = '1em' }) {
interface SpinnerProps {
className?: string;
size?: string | number;
color?: string;
bgOpacity?: number;
speed?: number;
}
export default function Spinner({
className = 'm-auto',
size = 20,
color = 'currentColor',
bgOpacity = 0.1,
speed = 0.75,
}: SpinnerProps) {
const cssVars = {
'--spinner-speed': `${speed}s`,
} as React.CSSProperties;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={cn(className, 'spinner')}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('animate-spin', className)}
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
style={cssVars}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
<defs>
<style type="text/css">{`
.spinner {
transform-origin: center;
overflow: visible;
animation: spinner-rotate var(--spinner-speed) linear infinite;
}
@keyframes spinner-rotate {
to { transform: rotate(360deg); }
}
`}</style>
</defs>
<circle
cx="20"
cy="20"
r="14.5"
pathLength="100"
strokeWidth="5"
fill="none"
stroke={color}
strokeOpacity={bgOpacity}
/>
<circle
cx="20"
cy="20"
r="14.5"
pathLength="100"
strokeWidth="5"
fill="none"
stroke={color}
strokeDasharray="25 75"
strokeLinecap="round"
/>
</svg>
);
}

View file

@ -14,7 +14,7 @@ const buttonVariants = cva(
outline:
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
// hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',

View file

@ -32,7 +32,7 @@ const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (

View file

@ -0,0 +1,376 @@
import { useEffect, useRef, useCallback } from 'react';
import { cn } from '~/utils';
class Pixel {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
x: number;
y: number;
color: string;
speed: number;
size: number;
sizeStep: number;
minSize: number;
maxSizeInteger: number;
maxSize: number;
delay: number;
counter: number;
counterStep: number;
isIdle: boolean;
isReverse: boolean;
isShimmer: boolean;
activationThreshold: number;
constructor(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
speed: number,
delay: number,
activationThreshold: number,
) {
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.random(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.random(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
this.activationThreshold = activationThreshold;
}
private random(min: number, max: number) {
return Math.random() * (max - min) + min;
}
private draw() {
const offset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x + offset, this.y + offset, this.size, this.size);
}
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.draw();
}
appearWithProgress(progress: number) {
const diff = progress - this.activationThreshold;
if (diff <= 0) {
this.isIdle = true;
return;
}
if (this.counter <= this.delay) {
this.counter += this.counterStep;
this.isIdle = false;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.isIdle = false;
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
}
this.size -= 0.1;
this.draw();
}
private shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false;
}
this.size += this.isReverse ? -this.speed : this.speed;
}
}
const getEffectiveSpeed = (value: number, reducedMotion: boolean) => {
const parsed = parseInt(String(value), 10);
const throttle = 0.001;
if (parsed <= 0 || reducedMotion) {
return 0;
}
if (parsed >= 100) {
return 100 * throttle;
}
return parsed * throttle;
};
const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max);
const VARIANTS = {
default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false },
blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false },
yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false },
pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true },
} as const;
interface PixelCardProps {
variant?: keyof typeof VARIANTS;
gap?: number;
speed?: number;
colors?: string;
noFocus?: boolean;
className?: string;
progress?: number;
randomness?: number;
width?: string;
height?: string;
}
export default function PixelCard({
variant = 'default',
gap,
speed,
colors,
noFocus,
className = '',
progress,
randomness = 0.3,
width,
height,
}: PixelCardProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pixelsRef = useRef<Pixel[]>([]);
const animationRef = useRef<number>();
const timePrevRef = useRef(performance.now());
const progressRef = useRef<number | undefined>(progress);
const reducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
).current;
const cfg = VARIANTS[variant];
const g = gap ?? cfg.gap;
const s = speed ?? cfg.speed;
const palette = colors ?? cfg.colors;
const disableFocus = noFocus ?? cfg.noFocus;
const updateCanvasOpacity = useCallback(() => {
if (!canvasRef.current) {
return;
}
if (progressRef.current === undefined) {
canvasRef.current.style.opacity = '1';
return;
}
const fadeStart = 0.9;
const alpha =
progressRef.current >= fadeStart ? 1 - (progressRef.current - fadeStart) / 0.1 : 1;
canvasRef.current.style.opacity = String(clamp(alpha));
}, []);
const animate = useCallback(
(method: keyof Pixel) => {
animationRef.current = requestAnimationFrame(() => animate(method));
const now = performance.now();
const elapsed = now - timePrevRef.current;
if (elapsed < 1000 / 60) {
return;
}
timePrevRef.current = now - (elapsed % (1000 / 60));
const ctx = canvasRef.current?.getContext('2d');
if (!ctx || !canvasRef.current) {
return;
}
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let idle = true;
for (const p of pixelsRef.current) {
if (method === 'appearWithProgress') {
progressRef.current !== undefined
? p.appearWithProgress(progressRef.current)
: (p.isIdle = true);
} else {
// @ts-ignore dynamic dispatch
p[method]();
}
if (!p.isIdle) {
idle = false;
}
}
updateCanvasOpacity();
if (idle) {
cancelAnimationFrame(animationRef.current!);
}
},
[updateCanvasOpacity],
);
const startAnim = useCallback(
(m: keyof Pixel) => {
cancelAnimationFrame(animationRef.current!);
animationRef.current = requestAnimationFrame(() => animate(m));
},
[animate],
);
const initPixels = useCallback(() => {
if (!containerRef.current || !canvasRef.current) {
return;
}
const { width: cw, height: ch } = containerRef.current.getBoundingClientRect();
const ctx = canvasRef.current.getContext('2d');
canvasRef.current.width = Math.floor(cw);
canvasRef.current.height = Math.floor(ch);
const cols = palette.split(',');
const px: Pixel[] = [];
const cx = cw / 2;
const cy = ch / 2;
const maxDist = Math.hypot(cx, cy);
for (let x = 0; x < cw; x += g) {
for (let y = 0; y < ch; y += g) {
const color = cols[Math.floor(Math.random() * cols.length)];
const distNorm = Math.hypot(x - cx, y - cy) / maxDist;
const threshold = clamp(distNorm * (1 - randomness) + Math.random() * randomness);
const delay = reducedMotion ? 0 : distNorm * maxDist;
if (!ctx) {
continue;
}
px.push(
new Pixel(
canvasRef.current,
ctx,
x,
y,
color,
getEffectiveSpeed(s, reducedMotion),
delay,
threshold,
),
);
}
}
pixelsRef.current = px;
if (progressRef.current !== undefined) {
startAnim('appearWithProgress');
}
}, [g, palette, s, randomness, reducedMotion, startAnim]);
useEffect(() => {
progressRef.current = progress;
if (progress !== undefined) {
startAnim('appearWithProgress');
}
}, [progress, startAnim]);
useEffect(() => {
if (progress === undefined) {
cancelAnimationFrame(animationRef.current!);
}
}, [progress]);
useEffect(() => {
initPixels();
const obs = new ResizeObserver(initPixels);
containerRef.current && obs.observe(containerRef.current);
return () => {
obs.disconnect();
cancelAnimationFrame(animationRef.current!);
};
}, [initPixels]);
const hoverIn = () => progressRef.current === undefined && startAnim('appear');
const hoverOut = () => progressRef.current === undefined && startAnim('disappear');
const focusIn: React.FocusEventHandler<HTMLDivElement> = (e) => {
if (
!disableFocus &&
!e.currentTarget.contains(e.relatedTarget) &&
progressRef.current === undefined
) {
startAnim('appear');
}
};
const focusOut: React.FocusEventHandler<HTMLDivElement> = (e) => {
if (
!disableFocus &&
!e.currentTarget.contains(e.relatedTarget) &&
progressRef.current === undefined
) {
startAnim('disappear');
}
};
return (
<div
ref={containerRef}
style={{
width: width || '100%',
height: height || '100%',
}}
>
<div
className={cn(
'relative isolate grid select-none place-items-center overflow-hidden rounded-lg border border-border-light shadow-md transition-colors duration-200 ease-in-out',
className,
)}
style={{
width: '100%',
height: '100%',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
}}
onMouseEnter={hoverIn}
onMouseLeave={hoverOut}
onFocus={disableFocus ? undefined : focusIn}
onBlur={disableFocus ? undefined : focusOut}
tabIndex={disableFocus ? -1 : 0}
>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 block"
width={width && width !== 'auto' ? parseInt(String(width)) : undefined}
height={height && height !== 'auto' ? parseInt(String(height)) : undefined}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { render } from '@testing-library/react';
import SplitText from './SplitText';
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
describe('SplitText', () => {
it('renders emojis correctly', () => {
const emojis = ['🚧', '❤️‍🔥', '💜', '🦎', '❌', '✅', '⚠️'];
const originalText = emojis.join('');
const { container } = render(<SplitText text={originalText} />);
const textSpans = container.querySelectorAll('p > span > span.inline-block');
// Reconstruct the text by joining all span contents
const reconstructedText = Array.from(textSpans)
.map((span) => span.textContent)
.join('')
.trim();
// Compare the reconstructed text with the original
expect(reconstructedText).toBe(originalText);
// Check the first character specifically as the reconstructed text could hide issues
for (let i = 0; i < emojis.length; i++) {
expect(Array.from(textSpans)[i].textContent).toBe(emojis[i]);
}
});
});

View file

@ -15,6 +15,17 @@ interface SplitTextProps {
onLineCountChange?: (lineCount: number) => void;
}
const splitGraphemes = (text: string): string[] => {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segments = segmenter.segment(text);
return Array.from(segments).map((s) => s.segment);
} else {
// Fallback for browsers without Intl.Segmenter
return [...text];
}
};
const SplitText: React.FC<SplitTextProps> = ({
text = '',
className = '',
@ -28,7 +39,7 @@ const SplitText: React.FC<SplitTextProps> = ({
onLetterAnimationComplete,
onLineCountChange,
}) => {
const words = text.split(' ').map((word) => word.split(''));
const words = text.split(' ').map(splitGraphemes);
const letters = words.flat();
const [inView, setInView] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
@ -40,12 +51,12 @@ const SplitText: React.FC<SplitTextProps> = ({
from: animationFrom,
to: inView
? async (next: (props: any) => Promise<void>) => {
await next(animationTo);
animatedCount.current += 1;
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
onLetterAnimationComplete();
await next(animationTo);
animatedCount.current += 1;
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
onLetterAnimationComplete();
}
}
}
: animationFrom,
delay: i * delay,
config: { easing },

View file

@ -31,8 +31,9 @@ export { default as MCPIcon } from './MCPIcon';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as SplitText } from './SplitText';
export { default as FileUpload } from './FileUpload';
export { default as FormInput } from './FormInput';
export { default as PixelCard } from './PixelCard';
export { default as FileUpload } from './FileUpload';
export { default as DropdownPopup } from './DropdownPopup';
export { default as DelayedRender } from './DelayedRender';
export { default as ThemeSelector } from './ThemeSelector';

View file

@ -58,10 +58,11 @@ export default function usePresets() {
}
setDefaultPreset(defaultPreset);
if (!conversation?.conversationId || conversation.conversationId === 'new') {
newConversation({ preset: defaultPreset, modelsData });
newConversation({ preset: defaultPreset, modelsData, disableParams: true });
}
hasLoaded.current = true;
// dependencies are stable and only needed once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetsQuery.data, user, modelsData]);
const setPresets = useCallback(
@ -102,7 +103,7 @@ export default function usePresets() {
if (data.defaultPreset && data.presetId !== _defaultPreset?.presetId) {
message = `${toastTitle} ${localize('com_endpoint_preset_default')}`;
setDefaultPreset(data);
newConversation({ preset: data });
newConversation({ preset: data, disableParams: true });
} else if (preset.defaultPreset === false) {
setDefaultPreset(null);
message = `${toastTitle} ${localize('com_endpoint_preset_default_removed')}`;
@ -185,6 +186,7 @@ export default function usePresets() {
newPreset.iconURL = newPreset.iconURL ?? null;
newPreset.modelLabel = newPreset.modelLabel ?? null;
const isModular = isCurrentModular && isNewModular && shouldSwitch;
const disableParams = newPreset.defaultPreset === true;
if (isExistingConversation && isModular) {
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
@ -205,11 +207,12 @@ export default function usePresets() {
preset: currentConvo,
keepLatestMessage: true,
keepAddedConvos: true,
disableParams,
});
return;
}
newConversation({ preset: newPreset, keepAddedConvos: isModular });
newConversation({ preset: newPreset, keepAddedConvos: isModular, disableParams });
};
const onChangePreset = (preset: TPreset) => {

View file

@ -19,7 +19,6 @@ const useFileDeletion = ({
assistant_id?: string;
tool_resource?: EToolResources;
}) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_batch, setFileDeleteBatch] = useState<t.BatchFile[]>([]);
const setFilesToDelete = useSetFilesToDelete();
@ -109,22 +108,33 @@ const useFileDeletion = ({
const deleteFiles = useCallback(
({ files, setFiles }: { files: ExtendedFile[] | t.TFile[]; setFiles?: FileMapSetter }) => {
const batchFiles = files.map((_file) => {
const { file_id, embedded, filepath = '', source = FileSources.local } = _file;
const batchFiles: t.BatchFile[] = [];
for (const _file of files) {
const {
file_id,
embedded,
temp_file_id,
filepath = '',
source = FileSources.local,
} = _file;
return {
batchFiles.push({
source,
file_id,
filepath,
embedded,
};
});
temp_file_id,
embedded: embedded ?? false,
});
}
if (setFiles) {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
batchFiles.forEach((file) => {
updatedFiles.delete(file.file_id);
if (file.temp_file_id) {
updatedFiles.delete(file.temp_file_id);
}
});
const filesToUpdate = Object.fromEntries(updatedFiles);
setFilesToDelete(filesToUpdate);

View file

@ -258,35 +258,6 @@ export default function useQueryParams({
})();
}, [methods, submitMessage, conversation]);
useEffect(() => {
// Only proceed if we've already processed URL parameters but haven't yet handled submission
if (
!processedRef.current ||
submissionHandledRef.current ||
settingsAppliedRef.current ||
!validSettingsRef.current ||
!conversation
) {
return;
}
const allSettingsApplied = areSettingsApplied();
if (allSettingsApplied) {
settingsAppliedRef.current = true;
if (pendingSubmitRef.current) {
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
settingsTimeoutRef.current = null;
}
console.log('Settings fully applied, processing submission');
processSubmission();
}
}
}, [conversation, processSubmission, areSettingsApplied]);
useEffect(() => {
const processQueryParams = () => {
const queryParams: Record<string, string> = {};
@ -332,14 +303,15 @@ export default function useQueryParams({
/** Mark processing as complete and clean up as needed */
const success = () => {
const currentParams = new URLSearchParams(searchParams.toString());
const paramString = searchParams.toString();
const currentParams = new URLSearchParams(paramString);
currentParams.delete('prompt');
currentParams.delete('q');
currentParams.delete('submit');
setSearchParams(currentParams, { replace: true });
processedRef.current = true;
console.log('Parameters processed successfully');
console.log('Parameters processed successfully', paramString);
clearInterval(intervalId);
// Only clean URL if there's no pending submission
@ -417,4 +389,33 @@ export default function useQueryParams({
queryClient,
processSubmission,
]);
useEffect(() => {
// Only proceed if we've already processed URL parameters but haven't yet handled submission
if (
!processedRef.current ||
submissionHandledRef.current ||
settingsAppliedRef.current ||
!validSettingsRef.current ||
!conversation
) {
return;
}
const allSettingsApplied = areSettingsApplied();
if (allSettingsApplied) {
settingsAppliedRef.current = true;
if (pendingSubmitRef.current) {
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
settingsTimeoutRef.current = null;
}
console.log('Settings fully applied, processing submission');
processSubmission();
}
}
}, [conversation, processSubmission, areSettingsApplied]);
}

View file

@ -225,6 +225,7 @@ export default function useSelectMention({
newPreset.iconURL = newPreset.iconURL ?? null;
newPreset.modelLabel = newPreset.modelLabel ?? null;
const isModular = isCurrentModular && isNewModular && shouldSwitch;
const disableParams = newPreset.defaultPreset === true;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
template.spec = null;
@ -244,12 +245,17 @@ export default function useSelectMention({
preset: newPreset,
keepLatestMessage: true,
keepAddedConvos: true,
disableParams,
});
return;
}
logger.info('conversation', 'Switching conversation to new preset', template);
newConversation({ preset: newPreset, keepAddedConvos: isModular });
newConversation({
preset: newPreset,
keepAddedConvos: isModular,
disableParams,
});
},
[
modularChat,

View file

@ -71,6 +71,7 @@ const useNewConvo = (index = 0) => {
keepLatestMessage?: boolean,
keepAddedConvos?: boolean,
disableFocus?: boolean,
_disableParams?: boolean,
) => {
const modelsConfig = modelsData ?? modelsQuery.data;
const { endpoint = null } = conversation;
@ -87,6 +88,12 @@ const useNewConvo = (index = 0) => {
? defaultPreset
: preset;
const disableParams =
_disableParams ??
(activePreset?.presetId != null &&
activePreset.presetId &&
activePreset.presetId === defaultPreset?.presetId);
if (buildDefaultConversation) {
let defaultEndpoint = getDefaultEndpoint({
convoSetup: activePreset ?? conversation,
@ -148,6 +155,10 @@ const useNewConvo = (index = 0) => {
});
}
if (disableParams === true) {
conversation.disableParams = true;
}
if (!(keepAddedConvos ?? false)) {
clearAllConversations(true);
}
@ -160,7 +171,7 @@ const useNewConvo = (index = 0) => {
);
setConversation({
...conversation,
conversationId: 'new',
conversationId: Constants.NEW_CONVO as string,
});
} else {
logger.log('conversation', 'Setting conversation from `useNewConvo`', conversation);
@ -205,6 +216,7 @@ const useNewConvo = (index = 0) => {
buildDefault = true,
keepLatestMessage = false,
keepAddedConvos = false,
disableParams,
}: {
template?: Partial<TConversation>;
preset?: Partial<TPreset>;
@ -213,6 +225,7 @@ const useNewConvo = (index = 0) => {
disableFocus?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
} = {}) {
pauseGlobalAudio();
if (!saveBadgesState) {
@ -282,17 +295,19 @@ const useNewConvo = (index = 0) => {
keepLatestMessage,
keepAddedConvos,
disableFocus,
disableParams,
);
},
[
pauseGlobalAudio,
startupConfig,
saveDrafts,
switchToConversation,
files,
setFiles,
saveDrafts,
mutateAsync,
resetBadges,
startupConfig,
saveBadgesState,
pauseGlobalAudio,
switchToConversation,
],
);

View file

@ -460,9 +460,11 @@
"com_ui_2fa_setup": "Setup 2FA",
"com_ui_2fa_verified": "Successfully verified Two-Factor Authentication",
"com_ui_accept": "I accept",
"com_ui_action_button": "Action Button",
"com_ui_add": "Add",
"com_ui_add_model_preset": "Add a model or preset for an additional response",
"com_ui_add_multi_conversation": "Add multi-conversation",
"com_ui_adding_details": "Adding details",
"com_ui_admin": "Admin",
"com_ui_admin_access_warning": "Disabling Admin access to this feature may cause unexpected UI issues requiring refresh. If saved, the only way to revert is via the interface setting in librechat.yaml config which affects all roles.",
"com_ui_admin_settings": "Admin Settings",
@ -574,6 +576,7 @@
"com_ui_create": "Create",
"com_ui_create_link": "Create link",
"com_ui_create_prompt": "Create Prompt",
"com_ui_creating_image": "Creating image. May take a moment",
"com_ui_currently_production": "Currently in production",
"com_ui_custom": "Custom",
"com_ui_custom_header_name": "Custom Header Name",
@ -627,6 +630,7 @@
"com_ui_duplication_processing": "Duplicating conversation...",
"com_ui_duplication_success": "Successfully duplicated conversation",
"com_ui_edit": "Edit",
"com_ui_edit_editing_image": "Editing image",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Endpoint",
"com_ui_endpoint_menu": "LLM Endpoint Menu",
@ -641,8 +645,10 @@
"com_ui_expand_chat": "Expand Chat",
"com_ui_export_convo_modal": "Export Conversation Modal",
"com_ui_field_required": "This field is required",
"com_ui_files": "Files",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
"com_ui_final_touch": "Final touch",
"com_ui_finance": "Finance",
"com_ui_fork": "Fork",
"com_ui_fork_all_target": "Include all to/from here",
@ -674,6 +680,7 @@
"com_ui_generate_backup": "Generate Backup Codes",
"com_ui_generate_qrcode": "Generate QR Code",
"com_ui_generating": "Generating...",
"com_ui_getting_started": "Getting Started",
"com_ui_global_group": "something needs to go here. was empty",
"com_ui_go_back": "Go back",
"com_ui_go_to_conversation": "Go to conversation",
@ -684,6 +691,8 @@
"com_ui_hide_qr": "Hide QR Code",
"com_ui_host": "Host",
"com_ui_idea": "Ideas",
"com_ui_image_created": "Image created",
"com_ui_image_edited": "Image edited",
"com_ui_image_gen": "Image Gen",
"com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations",

View file

@ -6,6 +6,7 @@ import './style.css';
import './mobile.css';
import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex.js';
const container = document.getElementById('root');
const root = createRoot(container);

View file

@ -139,9 +139,9 @@
z-index: 66;
top: 0;
max-width: 320px;
/* max-width: 260px; */
bottom: 0;
right: 0
/* opacity: 0; */
@ -341,7 +341,7 @@
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] {
scrollbar-gutter: stable !important;
background-color: rgba(21, 21, 21, 0.5) !important;
background-color: rgba(205, 205, 205, 0.66) !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar {

View file

@ -106,10 +106,13 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
JSON.stringify(newValue),
);
const disableParams = newValue.disableParams === true;
const shouldUpdateParams =
index === 0 &&
!disableParams &&
newValue.createdAt === '' &&
JSON.stringify(newValue) !== JSON.stringify(oldValue) &&
(oldValue as TConversation)?.conversationId === 'new';
(oldValue as TConversation)?.conversationId === Constants.NEW_CONVO;
if (shouldUpdateParams) {
const newParams = createChatSearchParams(newValue);
@ -299,10 +302,10 @@ const conversationByKeySelector = selectorFamily({
key: 'conversationByKeySelector',
get:
(index: string | number) =>
({ get }) => {
const conversation = get(conversationByIndex(index));
return conversation;
},
({ get }) => {
const conversation = get(conversationByIndex(index));
return conversation;
},
});
function useClearSubmissionState() {
@ -361,24 +364,24 @@ const updateConversationSelector = selectorFamily({
get: () => () => null as Partial<TConversation> | null,
set:
(conversationId: string) =>
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
},
});
},
});
export default {

View file

@ -40,6 +40,17 @@
--red-800: #991b1b;
--red-900: #7f1d1d;
--red-950: #450a0a;
--amber-50: #fffbeb;
--amber-100: #fef3c7;
--amber-200: #fde68a;
--amber-300: #fcd34d;
--amber-400: #fbbf24;
--amber-500: #f59e0b;
--amber-600: #d97706;
--amber-700: #b45309;
--amber-800: #92400e;
--amber-900: #78350f;
--amber-950: #451a03;
--gizmo-gray-500: #999;
--gizmo-gray-600: #666;
--gizmo-gray-950: #0f0f0f;
@ -55,6 +66,7 @@ html {
--text-secondary: var(--gray-600);
--text-secondary-alt: var(--gray-500);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--ring-primary: var(--gray-500);
--header-primary: var(--white);
--header-hover: var(--gray-50);
@ -114,6 +126,7 @@ html {
--text-secondary: var(--gray-300);
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--header-primary: var(--gray-700);
--header-hover: var(--gray-600);
--header-button-hover: var(--gray-700);
@ -715,8 +728,8 @@ pre {
.premium-scroll-button:hover:not(:active) {
transform: translateY(-1.5px) scale(1.02);
box-shadow:
0 5px 10px rgba(0, 0, 0, 0.07),
box-shadow:
0 5px 10px rgba(0, 0, 0, 0.07),
0 7px 14px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
@ -2578,7 +2591,9 @@ html {
.animate-popover {
transform-origin: top;
opacity: 0;
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
transition:
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
transform: scale(0.95) translateY(-0.5rem);
}
@ -2590,7 +2605,9 @@ html {
.animate-popover-left {
transform-origin: left;
opacity: 0;
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
transition:
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
transform: scale(0.95) translateX(-0.5rem);
}
@ -2678,3 +2695,46 @@ html {
.badge-icon {
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
display: inline-block;
position: relative;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.8) 25%,
rgba(179, 179, 179, 0.25) 50%,
rgba(255, 255, 255, 0.8) 75%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
}
:global(.dark) .shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255) 25%,
rgba(129, 130, 134, 0.18) 50%,
rgb(255, 255, 255) 75%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
}
.custom-style-2 {
padding: 12px;
}

View file

@ -1,6 +1,12 @@
import { isAgentsEndpoint, isAssistantsEndpoint, Constants } from 'librechat-data-provider';
import {
Constants,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TConversation, TPreset } from 'librechat-data-provider';
const allowedParams = Object.keys(tQueryParamsSchema.shape);
export default function createChatSearchParams(
input: TConversation | TPreset | Record<string, string> | null,
): URLSearchParams {
@ -10,25 +16,6 @@ export default function createChatSearchParams(
const params = new URLSearchParams();
const allowedParams = [
'endpoint',
'model',
'temperature',
'presence_penalty',
'frequency_penalty',
'stop',
'top_p',
'max_tokens',
'topP',
'topK',
'maxOutputTokens',
'promptCache',
'region',
'maxTokens',
'agent_id',
'assistant_id',
];
if (input && typeof input === 'object' && !('endpoint' in input) && !('model' in input)) {
Object.entries(input as Record<string, string>).forEach(([key, value]) => {
if (value != null && allowedParams.includes(key)) {
@ -64,20 +51,15 @@ export default function createChatSearchParams(
params.set('model', conversation.model);
}
const paramMap = {
temperature: conversation.temperature,
presence_penalty: conversation.presence_penalty,
frequency_penalty: conversation.frequency_penalty,
stop: conversation.stop,
top_p: conversation.top_p,
max_tokens: conversation.max_tokens,
topP: conversation.topP,
topK: conversation.topK,
maxOutputTokens: conversation.maxOutputTokens,
promptCache: conversation.promptCache,
region: conversation.region,
maxTokens: conversation.maxTokens,
};
const paramMap: Record<string, any> = {};
allowedParams.forEach((key) => {
if (key === 'agent_id' && conversation.agent_id === Constants.EPHEMERAL_AGENT_ID) {
return;
}
if (key !== 'endpoint' && key !== 'model') {
paramMap[key] = (conversation as any)[key];
}
});
return Object.entries(paramMap).reduce((params, [key, value]) => {
if (value != null) {

View file

@ -18,6 +18,7 @@ export * from './promptGroups';
export { default as cn } from './cn';
export { default as logger } from './logger';
export { default as buildTree } from './buildTree';
export { default as scaleImage } from './scaleImage';
export { default as getLoginError } from './getLoginError';
export { default as cleanupPreset } from './cleanupPreset';
export { default as buildDefaultConvo } from './buildDefaultConvo';

View file

@ -0,0 +1,21 @@
export default function scaleImage({
originalWidth,
originalHeight,
containerRef,
}: {
originalWidth?: number;
originalHeight?: number;
containerRef: React.RefObject<HTMLDivElement>;
}) {
const containerWidth = containerRef.current?.offsetWidth ?? 0;
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
return { width: 'auto', height: 'auto' };
}
const aspectRatio = originalWidth / originalHeight;
const scaledWidth = Math.min(containerWidth, originalWidth);
const scaledHeight = scaledWidth / aspectRatio;
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
}

View file

@ -67,6 +67,7 @@ module.exports = {
'text-secondary': 'var(--text-secondary)',
'text-secondary-alt': 'var(--text-secondary-alt)',
'text-tertiary': 'var(--text-tertiary)',
'text-warning': 'var(--text-warning)',
'ring-primary': 'var(--ring-primary)',
'header-primary': 'var(--header-primary)',
'header-hover': 'var(--header-hover)',

View file

@ -0,0 +1,33 @@
apiVersion: v2
name: librechat-rag-api
description: A Helm chart for LibreChat
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.5.2
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
# renovate: image=ghcr.io/danny-avila/librechat-rag-api-dev
appVersion: "v0.4.0"
home: https://www.librechat.ai
dependencies:
- name: postgresql
version: "15.5.38"
condition: postgresql.enabled
repository: "https://charts.bitnami.com/bitnami"

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