Compare commits
12 Commits
feat/Multi
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a69e8b6b | ||
|
|
7faff2c75f | ||
|
|
083710d4c9 | ||
|
|
c9b04ef1b4 | ||
|
|
81fe64da05 | ||
|
|
b0ebc265a3 | ||
|
|
e5743a0b10 | ||
|
|
ec5c9fef48 | ||
|
|
f74b9a3018 | ||
|
|
1083014464 | ||
|
|
124533f09f | ||
|
|
c77d13d269 |
21
.env.example
21
.env.example
@@ -20,8 +20,8 @@ DOMAIN_CLIENT=http://localhost:3080
|
||||
DOMAIN_SERVER=http://localhost:3080
|
||||
|
||||
NO_INDEX=true
|
||||
# Use the address that is at most n number of hops away from the Express application.
|
||||
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
|
||||
# Use the address that is at most n number of hops away from the Express application.
|
||||
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
|
||||
# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
|
||||
# Defaulted to 1.
|
||||
TRUST_PROXY=1
|
||||
@@ -424,15 +424,16 @@ APPLE_PRIVATE_KEY_PATH=
|
||||
APPLE_CALLBACK_URL=/oauth/apple/callback
|
||||
|
||||
# OpenID
|
||||
OPENID_ENABLED=
|
||||
OPENID_MULTI_TENANT=
|
||||
#OPENID_CLIENT_ID=
|
||||
#OPENID_CLIENT_SECRET=
|
||||
#OPENID_ISSUER=
|
||||
OPENID_CLIENT_ID=
|
||||
OPENID_CLIENT_SECRET=
|
||||
OPENID_ISSUER=
|
||||
OPENID_SESSION_SECRET=
|
||||
# OPENID_USE_PKCE=
|
||||
OPENID_SCOPE="openid profile email"
|
||||
OPENID_CALLBACK_URL=/oauth/openid/callback
|
||||
OPENID_REQUIRED_ROLE=
|
||||
# Set to 'userinfo' or 'token' to determine witch role source to use, Default is 'token'
|
||||
OPENID_REQUIRED_ROLE_SOURCE=
|
||||
OPENID_REQUIRED_ROLE_TOKEN_KIND=
|
||||
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's username
|
||||
@@ -565,9 +566,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# users always get the latest version. Customize #
|
||||
# only if you understand caching implications. #
|
||||
|
||||
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||
# INDEX_PRAGMA=no-cache
|
||||
# INDEX_EXPIRES=0
|
||||
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||
# INDEX_HTML_PRAGMA=no-cache
|
||||
# INDEX_HTML_EXPIRES=0
|
||||
|
||||
# no-cache: Forces validation with server before using cached version
|
||||
# no-store: Prevents storing the response entirely
|
||||
|
||||
3
.github/workflows/helmcharts.yml
vendored
3
.github/workflows/helmcharts.yml
vendored
@@ -29,8 +29,5 @@ 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 }}"
|
||||
|
||||
2
.github/workflows/i18n-unused-keys.yml
vendored
2
.github/workflows/i18n-unused-keys.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
# Define paths
|
||||
I18N_FILE="client/src/locales/en/translation.json"
|
||||
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
|
||||
SOURCE_DIRS=("client/src" "api")
|
||||
|
||||
# Check if translation file exists
|
||||
if [[ ! -f "$I18N_FILE" ]]; then
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -113,11 +113,4 @@ uploads/
|
||||
|
||||
# owner
|
||||
release/
|
||||
|
||||
# Helm
|
||||
helm/librechat/Chart.lock
|
||||
helm/**/charts/
|
||||
helm/**/.values.yaml
|
||||
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -5,38 +5,23 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +67,7 @@ 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,19 +21,7 @@ const Agent = mongoose.model('agent', agentSchema);
|
||||
* @throws {Error} If the agent creation fails.
|
||||
*/
|
||||
const createAgent = async (agentData) => {
|
||||
const { versions, ...versionData } = agentData;
|
||||
const timestamp = new Date();
|
||||
const initialAgentData = {
|
||||
...agentData,
|
||||
versions: [
|
||||
{
|
||||
...versionData,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
],
|
||||
};
|
||||
return (await Agent.create(initialAgentData)).toObject();
|
||||
return (await Agent.create(agentData)).toObject();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,8 +103,6 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.author.toString() === req.user.id) {
|
||||
return agent;
|
||||
}
|
||||
@@ -141,146 +127,18 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a version already exists in the versions array, excluding timestamp and author fields
|
||||
* @param {Object} updateData - The update data to compare
|
||||
* @param {Array} versions - The existing versions array
|
||||
* @returns {Object|null} - The matching version if found, null otherwise
|
||||
*/
|
||||
const isDuplicateVersion = (updateData, currentData, versions) => {
|
||||
if (!versions || versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excludeFields = [
|
||||
'_id',
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'author',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'__v',
|
||||
'agent_ids',
|
||||
'versions',
|
||||
];
|
||||
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wouldBeVersion = { ...currentData, ...directUpdates };
|
||||
const lastVersion = versions[versions.length - 1];
|
||||
|
||||
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
|
||||
|
||||
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
|
||||
|
||||
let isMatch = true;
|
||||
for (const field of importantFields) {
|
||||
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for projectIds (MongoDB ObjectIds)
|
||||
if (field === 'projectIds') {
|
||||
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||
|
||||
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle arrays of objects like tool_kwargs
|
||||
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
||||
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||
const sortedVersion = [...lastVersion[field]].sort();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (field === 'model_parameters') {
|
||||
const wouldBeParams = wouldBeVersion[field] || {};
|
||||
const lastVersionParams = lastVersion[field] || {};
|
||||
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return isMatch ? lastVersion : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an agent with new data without overwriting existing
|
||||
* properties, or create a new agent if it doesn't exist.
|
||||
* When an agent is updated, a copy of the current state will be saved to the versions array.
|
||||
*
|
||||
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
||||
* @param {string} searchParameter.id - The ID of the agent to update.
|
||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||
* @param {Object} updateData - An object containing the properties to update.
|
||||
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
||||
* @throws {Error} If the update would create a duplicate version
|
||||
*/
|
||||
const updateAgent = async (searchParameter, updateData) => {
|
||||
const options = { new: true, upsert: false };
|
||||
|
||||
const currentAgent = await Agent.findOne(searchParameter);
|
||||
if (currentAgent) {
|
||||
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
|
||||
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
|
||||
if (duplicateVersion) {
|
||||
const error = new Error(
|
||||
'Duplicate version: This would create a version identical to an existing one',
|
||||
);
|
||||
error.statusCode = 409;
|
||||
error.details = {
|
||||
duplicateVersion,
|
||||
versionIndex: versions.findIndex(
|
||||
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
|
||||
),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
updateData.$push = {
|
||||
...($push || {}),
|
||||
versions: {
|
||||
...versionData,
|
||||
...directUpdates,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
||||
};
|
||||
|
||||
@@ -500,38 +358,6 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
||||
return await getAgent({ id: agentId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts an agent to a specific version in its version history.
|
||||
* @param {Object} searchParameter - The search parameters to find the agent to revert.
|
||||
* @param {string} searchParameter.id - The ID of the agent to revert.
|
||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||
* @param {number} versionIndex - The index of the version to revert to in the versions array.
|
||||
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
|
||||
* @throws {Error} If the agent is not found or the specified version does not exist.
|
||||
*/
|
||||
const revertAgentVersion = async (searchParameter, versionIndex) => {
|
||||
const agent = await Agent.findOne(searchParameter);
|
||||
if (!agent) {
|
||||
throw new Error('Agent not found');
|
||||
}
|
||||
|
||||
if (!agent.versions || !agent.versions[versionIndex]) {
|
||||
throw new Error(`Version ${versionIndex} not found`);
|
||||
}
|
||||
|
||||
const revertToVersion = agent.versions[versionIndex];
|
||||
|
||||
const updateData = {
|
||||
...revertToVersion,
|
||||
};
|
||||
|
||||
delete updateData._id;
|
||||
delete updateData.id;
|
||||
delete updateData.versions;
|
||||
|
||||
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Agent,
|
||||
getAgent,
|
||||
@@ -543,5 +369,4 @@ module.exports = {
|
||||
updateAgentProjects,
|
||||
addAgentResourceFile,
|
||||
removeAgentResourceFiles,
|
||||
revertAgentVersion,
|
||||
};
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
const originalEnv = {
|
||||
CREDS_KEY: process.env.CREDS_KEY,
|
||||
CREDS_IV: process.env.CREDS_IV,
|
||||
};
|
||||
|
||||
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
||||
process.env.CREDS_IV = '0123456789abcdef';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const {
|
||||
Agent,
|
||||
addAgentResourceFile,
|
||||
removeAgentResourceFiles,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
getAgent,
|
||||
deleteAgent,
|
||||
getListAgents,
|
||||
updateAgentProjects,
|
||||
} = require('./Agent');
|
||||
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
|
||||
|
||||
describe('Agent Resource File Operations', () => {
|
||||
let mongoServer;
|
||||
@@ -33,8 +15,6 @@ describe('Agent Resource File Operations', () => {
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
|
||||
process.env.CREDS_IV = originalEnv.CREDS_IV;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -352,537 +332,3 @@ describe('Agent Resource File Operations', () => {
|
||||
expect(finalFileIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent CRUD Operations', () => {
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
});
|
||||
|
||||
test('should create and get an agent', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
const newAgent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
description: 'Test description',
|
||||
});
|
||||
|
||||
expect(newAgent).toBeDefined();
|
||||
expect(newAgent.id).toBe(agentId);
|
||||
expect(newAgent.name).toBe('Test Agent');
|
||||
|
||||
const retrievedAgent = await getAgent({ id: agentId });
|
||||
expect(retrievedAgent).toBeDefined();
|
||||
expect(retrievedAgent.id).toBe(agentId);
|
||||
expect(retrievedAgent.name).toBe('Test Agent');
|
||||
expect(retrievedAgent.description).toBe('Test description');
|
||||
});
|
||||
|
||||
test('should delete an agent', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Agent To Delete',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
|
||||
const agentBeforeDelete = await getAgent({ id: agentId });
|
||||
expect(agentBeforeDelete).toBeDefined();
|
||||
|
||||
await deleteAgent({ id: agentId });
|
||||
|
||||
const agentAfterDelete = await getAgent({ id: agentId });
|
||||
expect(agentAfterDelete).toBeNull();
|
||||
});
|
||||
|
||||
test('should list agents by author', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||
|
||||
const agentIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = `agent_${uuidv4()}`;
|
||||
agentIds.push(id);
|
||||
await createAgent({
|
||||
id,
|
||||
name: `Agent ${i}`,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createAgent({
|
||||
id: `other_agent_${uuidv4()}`,
|
||||
name: `Other Agent ${i}`,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: otherAuthorId,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getListAgents({ author: authorId.toString() });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data).toHaveLength(5);
|
||||
expect(result.has_more).toBe(true);
|
||||
|
||||
for (const agent of result.data) {
|
||||
expect(agent.author).toBe(authorId.toString());
|
||||
}
|
||||
});
|
||||
|
||||
test('should update agent projects', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId1 = new mongoose.Types.ObjectId();
|
||||
const projectId2 = new mongoose.Types.ObjectId();
|
||||
const projectId3 = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Project Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
projectIds: [projectId1],
|
||||
});
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
|
||||
);
|
||||
|
||||
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
|
||||
|
||||
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
|
||||
|
||||
const updatedAgent = await getAgent({ id: agentId });
|
||||
expect(updatedAgent.projectIds).toHaveLength(2);
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
|
||||
|
||||
await updateAgent({ id: agentId }, { projectIds: [] });
|
||||
|
||||
const emptyProjectsAgent = await getAgent({ id: agentId });
|
||||
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
|
||||
|
||||
const nonExistentId = `agent_${uuidv4()}`;
|
||||
await expect(
|
||||
updateAgentProjects({
|
||||
id: nonExistentId,
|
||||
projectIds: [projectId1],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should handle ephemeral agent loading', async () => {
|
||||
const agentId = 'ephemeral_test';
|
||||
const endpoint = 'openai';
|
||||
|
||||
const originalModule = jest.requireActual('librechat-data-provider');
|
||||
|
||||
const mockDataProvider = {
|
||||
...originalModule,
|
||||
Constants: {
|
||||
...originalModule.Constants,
|
||||
EPHEMERAL_AGENT_ID: 'ephemeral_test',
|
||||
},
|
||||
};
|
||||
|
||||
jest.doMock('librechat-data-provider', () => mockDataProvider);
|
||||
|
||||
const mockReq = {
|
||||
user: { id: 'user123' },
|
||||
body: {
|
||||
promptPrefix: 'This is a test instruction',
|
||||
ephemeralAgent: {
|
||||
execute_code: true,
|
||||
mcp: ['server1', 'server2'],
|
||||
},
|
||||
},
|
||||
app: {
|
||||
locals: {
|
||||
availableTools: {
|
||||
tool__server1: {},
|
||||
tool__server2: {},
|
||||
another_tool: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const params = {
|
||||
req: mockReq,
|
||||
agent_id: agentId,
|
||||
endpoint,
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
expect(agentId).toBeDefined();
|
||||
expect(endpoint).toBeDefined();
|
||||
|
||||
jest.dontMock('librechat-data-provider');
|
||||
});
|
||||
|
||||
test('should handle loadAgent functionality and errors', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Load Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
tools: ['tool1', 'tool2'],
|
||||
});
|
||||
|
||||
const agent = await getAgent({ id: agentId });
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent.id).toBe(agentId);
|
||||
expect(agent.name).toBe('Test Load Agent');
|
||||
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
|
||||
|
||||
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
|
||||
const loadedAgent = await mockLoadAgent();
|
||||
expect(loadedAgent).toBeDefined();
|
||||
expect(loadedAgent.id).toBe(agentId);
|
||||
|
||||
const nonExistentId = `agent_${uuidv4()}`;
|
||||
const nonExistentAgent = await getAgent({ id: nonExistentId });
|
||||
expect(nonExistentAgent).toBeNull();
|
||||
|
||||
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
|
||||
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Version History', () => {
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
});
|
||||
|
||||
test('should create an agent with a single entry in versions array', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
expect(agent.versions).toBeDefined();
|
||||
expect(Array.isArray(agent.versions)).toBe(true);
|
||||
expect(agent.versions).toHaveLength(1);
|
||||
expect(agent.versions[0].name).toBe('Test Agent');
|
||||
expect(agent.versions[0].provider).toBe('test');
|
||||
expect(agent.versions[0].model).toBe('test-model');
|
||||
});
|
||||
|
||||
test('should accumulate version history across multiple updates', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const author = new mongoose.Types.ObjectId();
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'First Name',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author,
|
||||
description: 'First description',
|
||||
});
|
||||
|
||||
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
|
||||
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
|
||||
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
|
||||
|
||||
expect(finalAgent.versions).toBeDefined();
|
||||
expect(Array.isArray(finalAgent.versions)).toBe(true);
|
||||
expect(finalAgent.versions).toHaveLength(4);
|
||||
|
||||
expect(finalAgent.versions[0].name).toBe('First Name');
|
||||
expect(finalAgent.versions[0].description).toBe('First description');
|
||||
expect(finalAgent.versions[0].model).toBe('test-model');
|
||||
|
||||
expect(finalAgent.versions[1].name).toBe('Second Name');
|
||||
expect(finalAgent.versions[1].description).toBe('Second description');
|
||||
expect(finalAgent.versions[1].model).toBe('test-model');
|
||||
|
||||
expect(finalAgent.versions[2].name).toBe('Third Name');
|
||||
expect(finalAgent.versions[2].description).toBe('Second description');
|
||||
expect(finalAgent.versions[2].model).toBe('new-model');
|
||||
|
||||
expect(finalAgent.versions[3].name).toBe('Third Name');
|
||||
expect(finalAgent.versions[3].description).toBe('Final description');
|
||||
expect(finalAgent.versions[3].model).toBe('new-model');
|
||||
|
||||
expect(finalAgent.name).toBe('Third Name');
|
||||
expect(finalAgent.description).toBe('Final description');
|
||||
expect(finalAgent.model).toBe('new-model');
|
||||
});
|
||||
|
||||
test('should not include metadata fields in version history', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
|
||||
|
||||
expect(updatedAgent.versions).toHaveLength(2);
|
||||
expect(updatedAgent.versions[0]._id).toBeUndefined();
|
||||
expect(updatedAgent.versions[0].__v).toBeUndefined();
|
||||
expect(updatedAgent.versions[0].name).toBe('Test Agent');
|
||||
expect(updatedAgent.versions[0].author).toBeDefined();
|
||||
|
||||
expect(updatedAgent.versions[1]._id).toBeUndefined();
|
||||
expect(updatedAgent.versions[1].__v).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not recursively include previous versions', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
|
||||
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
|
||||
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
|
||||
|
||||
expect(finalAgent.versions).toHaveLength(4);
|
||||
|
||||
finalAgent.versions.forEach((version) => {
|
||||
expect(version.versions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle MongoDB operators and field updates correctly', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'MongoDB Operator Test',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
tools: ['tool1'],
|
||||
});
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
description: 'Updated description',
|
||||
$push: { tools: 'tool2' },
|
||||
$addToSet: { projectIds: projectId },
|
||||
},
|
||||
);
|
||||
|
||||
const firstUpdate = await getAgent({ id: agentId });
|
||||
expect(firstUpdate.description).toBe('Updated description');
|
||||
expect(firstUpdate.tools).toContain('tool1');
|
||||
expect(firstUpdate.tools).toContain('tool2');
|
||||
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
|
||||
expect(firstUpdate.versions).toHaveLength(2);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
tools: ['tool2', 'tool3'],
|
||||
},
|
||||
);
|
||||
|
||||
const secondUpdate = await getAgent({ id: agentId });
|
||||
expect(secondUpdate.tools).toHaveLength(2);
|
||||
expect(secondUpdate.tools).toContain('tool2');
|
||||
expect(secondUpdate.tools).toContain('tool3');
|
||||
expect(secondUpdate.tools).not.toContain('tool1');
|
||||
expect(secondUpdate.versions).toHaveLength(3);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
$push: { tools: 'tool3' },
|
||||
},
|
||||
);
|
||||
|
||||
const thirdUpdate = await getAgent({ id: agentId });
|
||||
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
|
||||
expect(toolCount).toBe(2);
|
||||
expect(thirdUpdate.versions).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle parameter objects correctly', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Parameters Test',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
model_parameters: { temperature: 0.7 },
|
||||
});
|
||||
|
||||
const updatedAgent = await updateAgent(
|
||||
{ id: agentId },
|
||||
{ model_parameters: { temperature: 0.8 } },
|
||||
);
|
||||
|
||||
expect(updatedAgent.versions).toHaveLength(2);
|
||||
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
model_parameters: {
|
||||
temperature: 0.8,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const complexAgent = await getAgent({ id: agentId });
|
||||
expect(complexAgent.versions).toHaveLength(3);
|
||||
expect(complexAgent.model_parameters.temperature).toBe(0.8);
|
||||
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
|
||||
|
||||
await updateAgent({ id: agentId }, { model_parameters: {} });
|
||||
|
||||
const emptyParamsAgent = await getAgent({ id: agentId });
|
||||
expect(emptyParamsAgent.versions).toHaveLength(4);
|
||||
expect(emptyParamsAgent.model_parameters).toEqual({});
|
||||
});
|
||||
|
||||
test('should detect duplicate versions and reject updates', async () => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
try {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId1 = new mongoose.Types.ObjectId();
|
||||
const projectId2 = new mongoose.Types.ObjectId();
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'simple field update',
|
||||
initial: {
|
||||
name: 'Test Agent',
|
||||
description: 'Initial description',
|
||||
},
|
||||
update: { name: 'Updated Name' },
|
||||
duplicate: { name: 'Updated Name' },
|
||||
},
|
||||
{
|
||||
name: 'object field update',
|
||||
initial: {
|
||||
model_parameters: { temperature: 0.7 },
|
||||
},
|
||||
update: { model_parameters: { temperature: 0.8 } },
|
||||
duplicate: { model_parameters: { temperature: 0.8 } },
|
||||
},
|
||||
{
|
||||
name: 'array field update',
|
||||
initial: {
|
||||
tools: ['tool1', 'tool2'],
|
||||
},
|
||||
update: { tools: ['tool2', 'tool3'] },
|
||||
duplicate: { tools: ['tool2', 'tool3'] },
|
||||
},
|
||||
{
|
||||
name: 'projectIds update',
|
||||
initial: {
|
||||
projectIds: [projectId1],
|
||||
},
|
||||
update: { projectIds: [projectId1, projectId2] },
|
||||
duplicate: { projectIds: [projectId2, projectId1] },
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const testAgentId = `agent_${uuidv4()}`;
|
||||
|
||||
await createAgent({
|
||||
id: testAgentId,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
...testCase.initial,
|
||||
});
|
||||
|
||||
await updateAgent({ id: testAgentId }, testCase.update);
|
||||
|
||||
let error;
|
||||
try {
|
||||
await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toContain('Duplicate version');
|
||||
expect(error.statusCode).toBe(409);
|
||||
expect(error.details).toBeDefined();
|
||||
expect(error.details.duplicateVersion).toBeDefined();
|
||||
|
||||
const agent = await getAgent({ id: testAgentId });
|
||||
expect(agent.versions).toHaveLength(2);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"mime": "^3.0.0",
|
||||
"module-alias": "^2.2.3",
|
||||
"mongoose": "^8.12.1",
|
||||
"multer": "^2.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ollama": "^0.5.0",
|
||||
|
||||
@@ -23,7 +23,6 @@ const { updateAction, getActions } = require('~/models/Action');
|
||||
const { updateAgentProjects } = require('~/models/Agent');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { deleteFileByFilter } = require('~/models/File');
|
||||
const { revertAgentVersion } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const systemTools = {
|
||||
@@ -105,8 +104,6 @@ const getAgentHandler = async (req, res) => {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||
const originalUrl = agent.avatar.filepath;
|
||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||
@@ -130,7 +127,6 @@ const getAgentHandler = async (req, res) => {
|
||||
author: agent.author,
|
||||
projectIds: agent.projectIds,
|
||||
isCollaborative: agent.isCollaborative,
|
||||
version: agent.version,
|
||||
});
|
||||
}
|
||||
return res.status(200).json(agent);
|
||||
@@ -191,14 +187,6 @@ const updateAgentHandler = async (req, res) => {
|
||||
return res.json(updatedAgent);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
||||
|
||||
if (error.statusCode === 409) {
|
||||
return res.status(409).json({
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
@@ -423,66 +411,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version from its version history.
|
||||
* @route PATCH /agents/:id/revert
|
||||
* @param {object} req - Express Request object
|
||||
* @param {object} req.params - Request parameters
|
||||
* @param {string} req.params.id - The ID of the agent to revert
|
||||
* @param {object} req.body - Request body
|
||||
* @param {number} req.body.version_index - The index of the version to revert to
|
||||
* @param {object} req.user - Authenticated user information
|
||||
* @param {string} req.user.id - User ID
|
||||
* @param {string} req.user.role - User role
|
||||
* @param {ServerResponse} res - Express Response object
|
||||
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
||||
* @throws {Error} 400 - If version_index is missing
|
||||
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
||||
* @throws {Error} 404 - If agent not found
|
||||
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
||||
*/
|
||||
const revertAgentVersionHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { version_index } = req.body;
|
||||
|
||||
if (version_index === undefined) {
|
||||
return res.status(400).json({ error: 'version_index is required' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||
|
||||
if (updatedAgent.author) {
|
||||
updatedAgent.author = updatedAgent.author.toString();
|
||||
}
|
||||
|
||||
if (updatedAgent.author !== req.user.id) {
|
||||
delete updatedAgent.author;
|
||||
}
|
||||
|
||||
return res.json(updatedAgent);
|
||||
} catch (error) {
|
||||
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createAgent: createAgentHandler,
|
||||
getAgent: getAgentHandler,
|
||||
@@ -491,5 +419,4 @@ module.exports = {
|
||||
deleteAgent: deleteAgentHandler,
|
||||
getListAgents: getListAgentsHandler,
|
||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||
revertAgentVersion: revertAgentVersionHandler,
|
||||
};
|
||||
|
||||
@@ -326,15 +326,8 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,13 +24,10 @@ const routes = require('./routes');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||
|
||||
// Allow PORT=0 to be used for automatic free port assignment
|
||||
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
||||
const port = Number(PORT) || 3080;
|
||||
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';
|
||||
@@ -39,9 +36,8 @@ 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');
|
||||
@@ -53,24 +49,23 @@ const startServer = async () => {
|
||||
app.use(noIndex);
|
||||
app.use(errorController);
|
||||
app.use(express.json({ limit: '3mb' }));
|
||||
app.use(express.urlencoded({ extended: true, 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(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 ALLOW_SOCIAL_LOGIN=true to enable them.');
|
||||
console.warn(
|
||||
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
|
||||
);
|
||||
}
|
||||
|
||||
/* OAUTH */
|
||||
@@ -133,7 +128,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`,
|
||||
);
|
||||
@@ -181,6 +176,3 @@ process.on('uncaughtException', (err) => {
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// export app for easier testing purposes
|
||||
module.exports = app;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -78,15 +78,6 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
||||
*/
|
||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version.
|
||||
* @route POST /agents/:id/revert
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
*/
|
||||
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
|
||||
|
||||
/**
|
||||
* Returns a list of agents.
|
||||
* @route GET /agents
|
||||
|
||||
@@ -52,9 +52,10 @@ router.get('/', async function (req, res) {
|
||||
!!process.env.APPLE_KEY_ID &&
|
||||
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
||||
openidLoginEnabled:
|
||||
!!process.env.OPENID_ENABLED &&
|
||||
!!process.env.OPENID_CLIENT_ID &&
|
||||
!!process.env.OPENID_CLIENT_SECRET &&
|
||||
!!process.env.OPENID_ISSUER &&
|
||||
!!process.env.OPENID_SESSION_SECRET,
|
||||
openidMultiTenantEnabled: !!process.env.OPENID_MULTI_TENANT,
|
||||
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
||||
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
||||
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
|
||||
@@ -74,7 +75,6 @@ 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,
|
||||
|
||||
@@ -121,14 +121,6 @@ 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 });
|
||||
|
||||
@@ -10,7 +10,6 @@ const {
|
||||
} = require('~/server/middleware');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
const { chooseOpenIdStrategy } = require('~/server/utils/openidHelper');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -95,32 +94,20 @@ router.get(
|
||||
/**
|
||||
* OpenID Routes
|
||||
*/
|
||||
router.get('/openid', async (req, res, next) => {
|
||||
try {
|
||||
const strategy = await chooseOpenIdStrategy(req);
|
||||
console.log('OpenID login using strategy:', strategy);
|
||||
passport.authenticate(strategy, {
|
||||
session: false,
|
||||
})(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/openid',
|
||||
passport.authenticate('openid', {
|
||||
session: false,
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/openid/callback',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const strategy = await chooseOpenIdStrategy(req);
|
||||
passport.authenticate(strategy, {
|
||||
failureRedirect: `${domains.client}/oauth/error`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
})(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
passport.authenticate('openid', {
|
||||
failureRedirect: `${domains.client}/oauth/error`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
setBalanceConfig,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ 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');
|
||||
@@ -24,6 +23,7 @@ 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,7 +74,6 @@ 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,
|
||||
@@ -86,7 +85,6 @@ const AppService = async (app) => {
|
||||
availableTools,
|
||||
imageOutputType,
|
||||
interfaceConfig,
|
||||
turnstileConfig,
|
||||
balance,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,12 +46,6 @@ jest.mock('./ToolService', () => ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('./start/turnstile', () => ({
|
||||
loadTurnstileConfig: jest.fn(() => ({
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const azureGroups = [
|
||||
{
|
||||
@@ -92,10 +86,6 @@ const azureGroups = [
|
||||
|
||||
describe('AppService', () => {
|
||||
let app;
|
||||
const mockedTurnstileConfig = {
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
app = { locals: {} };
|
||||
@@ -117,7 +107,6 @@ describe('AppService', () => {
|
||||
sidePanel: true,
|
||||
presets: true,
|
||||
}),
|
||||
turnstileConfig: mockedTurnstileConfig,
|
||||
modelSpecs: undefined,
|
||||
availableTools: {
|
||||
ExampleTool: {
|
||||
|
||||
@@ -10,7 +10,17 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
* */
|
||||
async function getCustomConfig() {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig());
|
||||
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG);
|
||||
|
||||
if (!customConfig) {
|
||||
customConfig = await loadCustomConfig();
|
||||
}
|
||||
|
||||
if (!customConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return customConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,14 +29,7 @@ async function loadConfigEndpoints(req) {
|
||||
|
||||
for (let i = 0; i < customEndpoints.length; i++) {
|
||||
const endpoint = customEndpoints[i];
|
||||
const {
|
||||
baseURL,
|
||||
apiKey,
|
||||
name: configName,
|
||||
iconURL,
|
||||
modelDisplayLabel,
|
||||
customParams,
|
||||
} = endpoint;
|
||||
const { baseURL, apiKey, name: configName, iconURL, modelDisplayLabel } = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
|
||||
const resolvedApiKey = extractEnvVariable(apiKey);
|
||||
@@ -48,7 +41,6 @@ async function loadConfigEndpoints(req) {
|
||||
userProvideURL: isUserProvided(resolvedBaseURL),
|
||||
modelDisplayLabel,
|
||||
iconURL,
|
||||
customParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
const path = require('path');
|
||||
const {
|
||||
CacheKeys,
|
||||
configSchema,
|
||||
EImageOutputType,
|
||||
validateSettingDefinitions,
|
||||
agentParamSettings,
|
||||
paramSettings,
|
||||
} = require('librechat-data-provider');
|
||||
const { CacheKeys, configSchema, EImageOutputType } = 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');
|
||||
@@ -113,10 +105,6 @@ 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);
|
||||
@@ -129,52 +117,4 @@ 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;
|
||||
|
||||
@@ -1,34 +1,6 @@
|
||||
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');
|
||||
@@ -178,126 +150,4 @@ 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,6 @@ 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,
|
||||
|
||||
@@ -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.file_id}" from OpenAI`,
|
||||
`[deleteOpenAIFile] User ${req.user.id} successfully deleted ${file.file_id} from OpenAI`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[deleteOpenAIFile] Error deleting file from OpenAI: ' + error.message);
|
||||
|
||||
@@ -5,10 +5,9 @@ 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' | {percentage?: number, px?: number}} resolution - The resolution to resize the image to.
|
||||
* @param {'low' | 'high'} resolution - The resolution to resize the image to.
|
||||
* 'low' for a maximum of 512x512 resolution,
|
||||
* 'high' for a maximum of 768x2000 resolution,
|
||||
* or a custom object with percentage or px values.
|
||||
* 'high' for a maximum of 768x2000 resolution.
|
||||
* @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.
|
||||
@@ -18,32 +17,10 @@ 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 (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') {
|
||||
if (resolution === 'low') {
|
||||
resizeOptions.width = maxLowRes;
|
||||
resizeOptions.height = maxLowRes;
|
||||
} else if (resolution === 'high') {
|
||||
|
||||
@@ -137,13 +137,11 @@ const processDeleteRequest = async ({ req, files }) => {
|
||||
/** @type {Record<string, OpenAI | undefined>} */
|
||||
const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined };
|
||||
const initializeClients = async () => {
|
||||
if (req.app.locals[EModelEndpoint.assistants]) {
|
||||
const openAIClient = await getOpenAIClient({
|
||||
req,
|
||||
overrideEndpoint: EModelEndpoint.assistants,
|
||||
});
|
||||
client[FileSources.openai] = openAIClient.openai;
|
||||
}
|
||||
const openAIClient = await getOpenAIClient({
|
||||
req,
|
||||
overrideEndpoint: EModelEndpoint.assistants,
|
||||
});
|
||||
client[FileSources.openai] = openAIClient.openai;
|
||||
|
||||
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
|
||||
return;
|
||||
@@ -695,7 +693,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, undefined, `${file_id}${fileExt}`);
|
||||
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
|
||||
const file = {
|
||||
..._file,
|
||||
usage: 1,
|
||||
@@ -840,9 +838,8 @@ function base64ToBuffer(base64String) {
|
||||
|
||||
async function saveBase64Image(
|
||||
url,
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
|
||||
) {
|
||||
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);
|
||||
@@ -855,7 +852,7 @@ async function saveBase64Image(
|
||||
}
|
||||
}
|
||||
|
||||
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint);
|
||||
const image = await resizeImageBuffer(inputBuffer, resolution, endpoint);
|
||||
const source = req.app.locals.fileStrategy;
|
||||
const { saveBuffer } = getStrategyFunctions(source);
|
||||
const filepath = await saveBuffer({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { normalizeServerName } = require('librechat-mcp');
|
||||
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
|
||||
const {
|
||||
Constants,
|
||||
@@ -39,7 +38,6 @@ 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(
|
||||
@@ -85,7 +83,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||
|
||||
const toolInstance = tool(_call, {
|
||||
schema,
|
||||
name: normalizedToolKey,
|
||||
name: toolKey,
|
||||
description: description || '',
|
||||
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const keyvRedis = require('~/cache/keyvRedis');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Express.Application} app
|
||||
*/
|
||||
const configureSocialLogins = (app) => {
|
||||
@@ -37,7 +38,10 @@ const configureSocialLogins = (app) => {
|
||||
passport.use(appleLogin());
|
||||
}
|
||||
if (
|
||||
process.env.OPENID_ENABLED &&
|
||||
process.env.OPENID_CLIENT_ID &&
|
||||
process.env.OPENID_CLIENT_SECRET &&
|
||||
process.env.OPENID_ISSUER &&
|
||||
process.env.OPENID_SCOPE &&
|
||||
process.env.OPENID_SESSION_SECRET
|
||||
) {
|
||||
logger.info('Configuring OpenID Connect...');
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
const { logger } = require('~/config');
|
||||
const { getCustomConfig } = require('~/server/services/Config');
|
||||
|
||||
/**
|
||||
* Loads the tenant configurations from the custom configuration.
|
||||
* @returns {Promise<Array>} Array of tenant configurations.
|
||||
*/
|
||||
async function getOpenIdTenants() {
|
||||
try {
|
||||
const customConfig = await getCustomConfig();
|
||||
if (customConfig?.openid?.tenants) {
|
||||
return customConfig.openid.tenants;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to load custom configuration for OpenID tenants:', err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the OpenID strategy name based on the email domain.
|
||||
* It consults the global tenant mapping (built in setupOpenId).
|
||||
* @param {import('express').Request} req - The Express request object.
|
||||
* @returns {Promise<string>} - The chosen strategy name.
|
||||
*/
|
||||
async function chooseOpenIdStrategy(req) {
|
||||
if (req.query.email) {
|
||||
const email = req.query.email;
|
||||
const domain = email.split('@')[1].toLowerCase();
|
||||
const tenants = await getOpenIdTenants();
|
||||
|
||||
// Iterate over the tenants and return the strategy name of the first matching tenant
|
||||
for (const tenant of tenants) {
|
||||
if (tenant.domains) {
|
||||
const tenantDomains = tenant.domains.split(',').map(s => s.trim().toLowerCase());
|
||||
if (tenantDomains.includes(domain)) {
|
||||
// Look up the registered strategy via the global mapping.
|
||||
if (tenant.name && tenant.name.trim() && global.__openidTenantMapping) {
|
||||
const mapped = global.__openidTenantMapping.get(tenant.name.trim().toLowerCase());
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return 'openid'; // Fallback if no mapping exists.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'openid';
|
||||
}
|
||||
|
||||
module.exports = { getOpenIdTenants, chooseOpenIdStrategy };
|
||||
@@ -14,7 +14,6 @@ const staticCache = (staticPath) =>
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
||||
}
|
||||
},
|
||||
index: false,
|
||||
});
|
||||
|
||||
module.exports = staticCache;
|
||||
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
|
||||
// Check required environment variables
|
||||
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
|
||||
module.exports = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchAttributes = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const { decode: jwtDecode } = require('jsonwebtoken');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -8,7 +8,6 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||
const { hashToken } = require('~/server/utils/crypto');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
@@ -18,12 +17,15 @@ try {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an image from a URL using an access token.
|
||||
* @param {string} url
|
||||
* @param {string} accessToken
|
||||
* @returns {Promise<Buffer>}
|
||||
* Downloads an image from a URL using an access token, returning a Buffer.
|
||||
*
|
||||
* @async
|
||||
* @function downloadImage
|
||||
* @param {string} url - The image URL
|
||||
* @param {string} accessToken - The OAuth2 access token, if required by the server
|
||||
* @returns {Promise<Buffer|string>} A Buffer if successful, or an empty string on failure
|
||||
*/
|
||||
const downloadImage = async (url, accessToken) => {
|
||||
async function downloadImage(url, accessToken) {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
@@ -31,34 +33,33 @@ const downloadImage = async (url, accessToken) => {
|
||||
try {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.agent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.ok) {
|
||||
const buffer = await response.buffer();
|
||||
return buffer;
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
||||
}
|
||||
return await response.buffer();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
|
||||
);
|
||||
logger.error(`[openidStrategy] downloadImage: Failed to fetch "${url}": ${error}`);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the full name of a user based on OpenID userinfo and environment configuration.
|
||||
* Derives a user's "full name" from userinfo or environment-specified claim.
|
||||
*
|
||||
* Priority:
|
||||
* 1) process.env.OPENID_NAME_CLAIM
|
||||
* 2) userinfo.given_name + userinfo.family_name
|
||||
* 3) userinfo.given_name OR userinfo.family_name
|
||||
* 4) userinfo.username or userinfo.email
|
||||
*
|
||||
* @function getFullName
|
||||
* @param {Object} userinfo - The user information object from OpenID Connect
|
||||
* @param {string} [userinfo.given_name] - The user's first name
|
||||
* @param {string} [userinfo.family_name] - The user's last name
|
||||
@@ -67,155 +68,252 @@ const downloadImage = async (url, accessToken) => {
|
||||
* @returns {string} The determined full name of the user
|
||||
*/
|
||||
function getFullName(userinfo) {
|
||||
if (process.env.OPENID_NAME_CLAIM) {
|
||||
if (process.env.OPENID_NAME_CLAIM && userinfo[process.env.OPENID_NAME_CLAIM]) {
|
||||
return userinfo[process.env.OPENID_NAME_CLAIM];
|
||||
}
|
||||
|
||||
if (userinfo.given_name && userinfo.family_name) {
|
||||
return `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
}
|
||||
|
||||
if (userinfo.given_name) {
|
||||
return userinfo.given_name;
|
||||
}
|
||||
|
||||
if (userinfo.family_name) {
|
||||
return userinfo.family_name;
|
||||
}
|
||||
|
||||
return userinfo.username || userinfo.email;
|
||||
return userinfo.username || userinfo.email || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
* If the input is a string, it will be returned as is.
|
||||
* If the input is an array, elements will be joined with underscores.
|
||||
* In case of undefined or other falsy values, a default value will be returned.
|
||||
*
|
||||
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
||||
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
||||
* @returns {string} The processed input as a string suitable for a username.
|
||||
* @function convertToUsername
|
||||
* @param {string|string[]|undefined} input - Could be a string or array of strings
|
||||
* @param {string} [defaultValue=''] - Fallback if input is invalid or not provided
|
||||
* @returns {string} A processed username string
|
||||
*/
|
||||
function convertToUsername(input, defaultValue = '') {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
} else if (Array.isArray(input)) {
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input.join('_');
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a single OpenID strategy for the given tenant configuration.
|
||||
* @param {Object} tenant - The tenant’s OpenID config (issuer, clientId, etc.).
|
||||
* @param {string} tenant.issuer
|
||||
* @param {string} tenant.clientId
|
||||
* @param {string} tenant.clientSecret
|
||||
* @param {string} strategyName - Unique name for the strategy.
|
||||
* Safely extracts an array of roles from an object using dot notation (e.g. realm_access.roles).
|
||||
*
|
||||
* @function extractRolesFrom
|
||||
* @param {Object} obj
|
||||
* @param {string} path
|
||||
* @returns {string[]} Array of roles, or empty array if not found
|
||||
*/
|
||||
async function setupSingleStrategy(tenant, strategyName) {
|
||||
function extractRolesFrom(obj, path) {
|
||||
try {
|
||||
// Discover the issuer (this performs the .well-known lookup).
|
||||
const issuer = await Issuer.discover(tenant.issuer);
|
||||
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
|
||||
- id_token_signed_response_alg // defaults to 'RS256'
|
||||
- request_object_signing_alg // defaults to 'RS256'
|
||||
- userinfo_signed_response_alg // not in v5
|
||||
- introspection_signed_response_alg // not in v5
|
||||
- authorization_signed_response_alg // not in v5
|
||||
*/
|
||||
let current = obj;
|
||||
for (const part of path.split('.')) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return [];
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return Array.isArray(current) ? current : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves user roles from either a token, the userinfo object, or both.
|
||||
*
|
||||
* Supports three strategies based on the roleSource:
|
||||
* - 'token': Extract roles from the token (access or id token), fallback to userinfo if extraction fails.
|
||||
* - 'userinfo': Extract roles solely from the userinfo object.
|
||||
* - 'both': Extract roles from both token and userinfo and merge them.
|
||||
*
|
||||
* Also supports encrypted tokens by falling back to userinfo if the token is not JWT-decodable.
|
||||
*
|
||||
* @function getUserRoles
|
||||
* @param {import('openid-client').TokenSet} tokenSet
|
||||
* @param {Object} userinfo
|
||||
* @param {string} rolePath - Dot-notation path to where roles are stored
|
||||
* @param {'access'|'id'} tokenKind - Which token to parse for roles
|
||||
* @param {'token'|'userinfo'|'both'} roleSource - Source of roles for extraction
|
||||
* @returns {string[]} Array of roles, possibly empty
|
||||
*/
|
||||
function getUserRoles(tokenSet, userinfo, rolePath, tokenKind, roleSource) {
|
||||
if (!tokenSet) {
|
||||
return extractRolesFrom(userinfo, rolePath);
|
||||
}
|
||||
|
||||
if (roleSource === 'userinfo') {
|
||||
const roles = extractRolesFrom(userinfo, rolePath);
|
||||
if (!roles.length) {
|
||||
logger.warn(`[openidStrategy] Key '${rolePath}' not found in userinfo.`);
|
||||
}
|
||||
return roles;
|
||||
} else if (roleSource === 'both') {
|
||||
let tokenRoles = [];
|
||||
try {
|
||||
let tokenToDecode = tokenKind === 'access' ? tokenSet.access_token : tokenSet.id_token;
|
||||
if (tokenToDecode && tokenToDecode.includes('.')) {
|
||||
const tokenData = jwtDecode(tokenToDecode);
|
||||
tokenRoles = extractRolesFrom(tokenData, rolePath);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[openidStrategy] Token is not a valid JWT for decoding, skipping token roles extraction.',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[openidStrategy] Failed to decode ${tokenKind} token: ${err}.`);
|
||||
}
|
||||
const userinfoRoles = extractRolesFrom(userinfo, rolePath);
|
||||
const combinedRoles = Array.from(new Set([...tokenRoles, ...userinfoRoles]));
|
||||
if (!combinedRoles.length) {
|
||||
logger.warn(`[openidStrategy] Key '${rolePath}' not found in both token and userinfo.`);
|
||||
}
|
||||
return combinedRoles;
|
||||
} else {
|
||||
// default 'token' strategy
|
||||
try {
|
||||
let tokenToDecode = tokenKind === 'access' ? tokenSet.access_token : tokenSet.id_token;
|
||||
if (!tokenToDecode || !tokenToDecode.includes('.')) {
|
||||
throw new Error('Token is not a valid JWT for decoding.');
|
||||
}
|
||||
const tokenData = jwtDecode(tokenToDecode);
|
||||
const roles = extractRolesFrom(tokenData, rolePath);
|
||||
if (!roles.length) {
|
||||
logger.warn(
|
||||
`[openidStrategy] Key '${rolePath}' not found in ${tokenKind} token. Falling back to userinfo.`,
|
||||
);
|
||||
return extractRolesFrom(userinfo, rolePath);
|
||||
}
|
||||
return roles;
|
||||
} catch (err) {
|
||||
logger.error(`[openidStrategy] ${err}. Falling back to userinfo for role extraction.`);
|
||||
return extractRolesFrom(userinfo, rolePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and configures the OpenID Connect strategy with Passport, enabling PKCE when toggled.
|
||||
*
|
||||
* @async
|
||||
* @function setupOpenId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
// Set up a proxy if specified
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({ agent: proxyAgent });
|
||||
logger.info(`[openidStrategy] Using proxy: ${process.env.PROXY}`);
|
||||
}
|
||||
|
||||
// Discover issuer configuration
|
||||
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
|
||||
logger.info(`[openidStrategy] Discovered issuer: ${issuer.issuer}`);
|
||||
|
||||
/**
|
||||
* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
|
||||
* - id_token_signed_response_alg // defaults to 'RS256'
|
||||
* - request_object_signing_alg // defaults to 'RS256'
|
||||
* - userinfo_signed_response_alg // not in v5
|
||||
* - introspection_signed_response_alg // not in v5
|
||||
* - authorization_signed_response_alg // not in v5
|
||||
*/
|
||||
/** @type {import('openid-client').ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: tenant.clientId,
|
||||
client_secret: tenant.clientSecret,
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET || '',
|
||||
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
|
||||
};
|
||||
|
||||
// Optionally force the first supported signing algorithm
|
||||
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
|
||||
clientMetadata.id_token_signed_response_alg =
|
||||
issuer.id_token_signing_alg_values_supported?.[0] || 'RS256';
|
||||
}
|
||||
|
||||
const client = new issuer.Client(clientMetadata);
|
||||
|
||||
// Determine whether to enable PKCE
|
||||
const usePKCE = process.env.OPENID_USE_PKCE === 'true';
|
||||
|
||||
// Set up authorization parameters. Include code_challenge_method if PKCE is enabled.
|
||||
const openidScope = process.env.OPENID_SCOPE || 'openid profile email';
|
||||
/** @type {import('openid-client').AuthorizationParameters} */
|
||||
const params = {
|
||||
scope: openidScope,
|
||||
response_type: 'code',
|
||||
};
|
||||
if (usePKCE) {
|
||||
params.code_challenge_method = 'S256'; // Enable PKCE by specifying the code challenge method
|
||||
}
|
||||
|
||||
// Role-based config
|
||||
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||
const openidLogin = new OpenIDStrategy(
|
||||
const rolePath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const tokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND || 'id'; // 'id'|'access'
|
||||
const roleSource = process.env.OPENID_REQUIRED_ROLE_SOURCE || 'both'; // 'token'|'userinfo'|'both'
|
||||
|
||||
// Create the Passport strategy using the new type-correct instantiation and toggle for PKCE
|
||||
const openidStrategy = new OpenIDStrategy(
|
||||
{
|
||||
client,
|
||||
params: {
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
},
|
||||
params,
|
||||
usePKCE,
|
||||
},
|
||||
async (tokenset, userinfo, done) => {
|
||||
async (tokenSet, userinfo, done) => {
|
||||
try {
|
||||
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
|
||||
logger.debug('[openidStrategy] verify login tokenset and userinfo', { tokenset, userinfo });
|
||||
logger.info(`[openidStrategy] Verifying login for sub=${userinfo.sub}`);
|
||||
|
||||
// Find user by openidId or fallback to email
|
||||
let user = await findUser({ openidId: userinfo.sub });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
if (!user && userinfo.email) {
|
||||
user = await findUser({ email: userinfo.email });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
|
||||
userinfo.email
|
||||
} for openidId: ${userinfo.sub}`,
|
||||
`[openidStrategy] User ${user ? 'found' : 'not found'} by email=${userinfo.email}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = getFullName(userinfo);
|
||||
|
||||
if (requiredRole) {
|
||||
let decodedToken = '';
|
||||
if (requiredRoleTokenKind === 'access') {
|
||||
decodedToken = jwtDecode(tokenset.access_token);
|
||||
} else if (requiredRoleTokenKind === 'id') {
|
||||
decodedToken = jwtDecode(tokenset.id_token);
|
||||
}
|
||||
const pathParts = requiredRoleParameterPath.split('.');
|
||||
let found = true;
|
||||
let roles = pathParts.reduce((o, key) => {
|
||||
if (o === null || o === undefined || !(key in o)) {
|
||||
found = false;
|
||||
return [];
|
||||
}
|
||||
return o[key];
|
||||
}, decodedToken);
|
||||
|
||||
if (!found) {
|
||||
logger.error(
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||
);
|
||||
}
|
||||
|
||||
// If a role is required, check user roles
|
||||
if (requiredRole && rolePath) {
|
||||
const roles = getUserRoles(tokenSet, userinfo, rolePath, tokenKind, roleSource);
|
||||
if (!roles.includes(requiredRole)) {
|
||||
logger.warn(
|
||||
`[openidStrategy] Missing required role "${requiredRole}". Roles: [${roles.join(', ')}]`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: `You must have the "${requiredRole}" role to log in.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let username = '';
|
||||
if (process.env.OPENID_USERNAME_CLAIM) {
|
||||
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
|
||||
} else {
|
||||
username = convertToUsername(
|
||||
userinfo.username || userinfo.given_name || userinfo.email,
|
||||
);
|
||||
}
|
||||
// Derive name and username
|
||||
const fullName = getFullName(userinfo);
|
||||
const username = process.env.OPENID_USERNAME_CLAIM
|
||||
? convertToUsername(userinfo[process.env.OPENID_USERNAME_CLAIM])
|
||||
: convertToUsername(userinfo.username || userinfo.given_name || userinfo.email);
|
||||
|
||||
// Create or update user
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
};
|
||||
user = await createUser(user, true, true);
|
||||
logger.info(`[openidStrategy] Creating a new user for sub=${userinfo.sub}`);
|
||||
user = await createUser(
|
||||
{
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: userinfo.email || '',
|
||||
emailVerified: Boolean(userinfo.email_verified) || false,
|
||||
name: fullName,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
user.provider = 'openid';
|
||||
user.openidId = userinfo.sub;
|
||||
@@ -223,112 +321,44 @@ async function setupSingleStrategy(tenant, strategyName) {
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
let fileName;
|
||||
if (crypto) {
|
||||
fileName = (await hashToken(userinfo.sub)) + '.png';
|
||||
} else {
|
||||
fileName = userinfo.sub + '.png';
|
||||
}
|
||||
|
||||
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token);
|
||||
// Fetch avatar if not manually overridden
|
||||
if (userinfo.picture && !String(user.avatar || '').includes('manual=true')) {
|
||||
const imageBuffer = await downloadImage(userinfo.picture, tokenSet.access_token);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const fileHash = crypto ? await hashToken(userinfo.sub) : userinfo.sub;
|
||||
const fileName = `${fileHash}.png`;
|
||||
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
if (imagePath) {
|
||||
user.avatar = imagePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist user changes
|
||||
user = await updateUser(user._id, user);
|
||||
|
||||
// Success
|
||||
logger.info(
|
||||
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
||||
{
|
||||
user: {
|
||||
openidId: user.openidId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
},
|
||||
`[openidStrategy] Login success for sub=${user.openidId}, email=${user.email}, username=${user.username}`,
|
||||
);
|
||||
|
||||
done(null, user);
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] login failed', err);
|
||||
done(err);
|
||||
logger.error('[openidStrategy] Login verification failed:', err);
|
||||
return done(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
passport.use(strategyName, openidLogin);
|
||||
logger.info(`Configured OpenID strategy [${strategyName}] for issuer: ${tenant.issuer}`);
|
||||
// Register the strategy under the 'openid' name
|
||||
passport.use('openid', openidStrategy);
|
||||
} catch (err) {
|
||||
logger.error(`[openidStrategy] Error configuring strategy "${strategyName}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the YAML configuration and registers strategies for multi-tenant OpenID Connect.
|
||||
*/
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
// If a proxy is configured, set it for openid-client.
|
||||
|
||||
// Set global HTTP options for openid-client
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({
|
||||
agent: proxyAgent,
|
||||
timeout: 10000, // 10,000ms = 10 seconds
|
||||
});
|
||||
logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY} with timeout 10000ms`);
|
||||
} else {
|
||||
custom.setHttpOptionsDefaults({
|
||||
timeout: 10000, // Increase the default timeout
|
||||
});
|
||||
logger.info('[openidStrategy] Set default timeout to 10000ms');
|
||||
}
|
||||
|
||||
const tenants = await getOpenIdTenants();
|
||||
|
||||
// Global mapping: tenant name (lowercase) -> strategy name.
|
||||
const tenantMapping = new Map();
|
||||
|
||||
// If there is one tenant with no domains specified, register it as the default "openid" strategy.
|
||||
if (tenants.length === 1 && (!tenants[0].domains || tenants[0].domains.trim() === '')) {
|
||||
await setupSingleStrategy(tenants[0].openid, 'openid');
|
||||
tenantMapping.set(tenants[0].name?.trim().toLowerCase() || 'openid', 'openid');
|
||||
logger.info('Configured single-tenant OpenID strategy as "openid"');
|
||||
} else {
|
||||
// Otherwise, iterate over each tenant.
|
||||
for (const tenantCfg of tenants) {
|
||||
const openidCfg = tenantCfg.openid;
|
||||
let strategyName = 'openid';
|
||||
if (tenantCfg.name && tenantCfg.name.trim()) {
|
||||
strategyName = `openid_${tenantCfg.name.trim()}`;
|
||||
}else {
|
||||
logger.warn(
|
||||
`[openidStrategy] Tenant with issuer ${openidCfg.issuer} has no domains specified; defaulting strategy name to "openid".`,
|
||||
);
|
||||
}
|
||||
await setupSingleStrategy(openidCfg, strategyName);
|
||||
if (tenantCfg.name && tenantCfg.name.trim()) {
|
||||
tenantMapping.set(tenantCfg.name.trim().toLowerCase(), strategyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store the tenant mapping globally so that the helper can choose the correct strategy.
|
||||
global.__openidTenantMapping = tenantMapping;
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy]', err);
|
||||
logger.error('[openidStrategy] Error setting up OpenID strategy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ jest.mock('openid-client');
|
||||
jest.mock('jsonwebtoken/decode');
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(() => ({
|
||||
// You can modify this mock as needed (here returning a dummy function)
|
||||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||||
})),
|
||||
}));
|
||||
@@ -23,27 +22,20 @@ jest.mock('~/server/utils/crypto', () => ({
|
||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||
}));
|
||||
jest.mock('~/server/utils', () => ({
|
||||
isEnabled: jest.fn(() => false), // default to false, override per test if needed
|
||||
isEnabled: jest.fn(() => false), // default to false; override per test if needed
|
||||
}));
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// IMPORTANT: Mock the openid helper to return our desired tenant configuration.
|
||||
jest.mock('~/server/utils/openidHelper', () => ({
|
||||
getOpenIdTenants: jest.fn(),
|
||||
chooseOpenIdStrategy: jest.fn(), // Not used in these tests.
|
||||
}));
|
||||
|
||||
// Import our mocked helper so we can set its return value.
|
||||
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
|
||||
|
||||
// Mock Issuer.discover so that setupOpenId gets a fake issuer and client
|
||||
// Update Issuer.discover mock so that the returned issuer has an 'issuer' property.
|
||||
Issuer.discover = jest.fn().mockResolvedValue({
|
||||
issuer: 'https://fake-issuer.com',
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
Client: jest.fn().mockImplementation((clientMetadata) => {
|
||||
return {
|
||||
@@ -52,7 +44,7 @@ Issuer.discover = jest.fn().mockResolvedValue({
|
||||
}),
|
||||
});
|
||||
|
||||
// To capture the verify callback from the strategy, we grab it from the mock constructor
|
||||
// To capture the verify callback from the strategy, we grab it from the mock constructor.
|
||||
let verifyCallback;
|
||||
OpenIDStrategy.mockImplementation((options, verify) => {
|
||||
verifyCallback = verify;
|
||||
@@ -60,21 +52,21 @@ OpenIDStrategy.mockImplementation((options, verify) => {
|
||||
});
|
||||
|
||||
describe('setupOpenId', () => {
|
||||
// Helper to wrap the verify callback in a promise
|
||||
// Helper to wrap the verify callback in a promise.
|
||||
const validate = (tokenset, userinfo) =>
|
||||
new Promise((resolve, reject) => {
|
||||
verifyCallback(tokenset, userinfo, (err, user, details) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ user, details });
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ user, details });
|
||||
});
|
||||
});
|
||||
|
||||
const tokenset = {
|
||||
id_token: 'fake_id_token',
|
||||
access_token: 'fake_access_token',
|
||||
// Default tokenset: tokens include a period to simulate a JWT.
|
||||
const validTokenSet = {
|
||||
id_token: 'header.payload.signature',
|
||||
access_token: 'header.payload.signature',
|
||||
};
|
||||
|
||||
const baseUserinfo = {
|
||||
@@ -86,13 +78,14 @@ describe('setupOpenId', () => {
|
||||
name: 'My Full',
|
||||
username: 'flast',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
roles: ['requiredRole'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear previous mock calls and reset implementations
|
||||
// Clear previous mock calls and reset implementations.
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset environment variables needed by the strategy
|
||||
// Reset environment variables needed by the strategy.
|
||||
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
|
||||
process.env.OPENID_CLIENT_ID = 'fake_client_id';
|
||||
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
|
||||
@@ -102,41 +95,29 @@ describe('setupOpenId', () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'token';
|
||||
delete process.env.OPENID_USERNAME_CLAIM;
|
||||
delete process.env.OPENID_NAME_CLAIM;
|
||||
delete process.env.PROXY;
|
||||
delete process.env.OPENID_USE_PKCE;
|
||||
delete process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM;
|
||||
|
||||
// Set up our mocked tenant configuration.
|
||||
// Here we simulate a single tenant with an empty domains field.
|
||||
// (Our updated multi-tenant code uses the tenant name to build the strategy.)
|
||||
getOpenIdTenants.mockResolvedValue([
|
||||
{
|
||||
name: 'tenant1',
|
||||
domains: '', // Using an empty string so the single-tenant branch is taken.
|
||||
openid: {
|
||||
issuer: process.env.OPENID_ISSUER,
|
||||
clientId: process.env.OPENID_CLIENT_ID,
|
||||
clientSecret: process.env.OPENID_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Default jwtDecode mock returns a token that includes the required role.
|
||||
// By default, jwtDecode returns a token that includes the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
});
|
||||
|
||||
// By default, assume that no user is found, so createUser will be called
|
||||
// By default, assume that no user is found so that createUser will be called.
|
||||
findUser.mockResolvedValue(null);
|
||||
createUser.mockImplementation(async (userData) => {
|
||||
// simulate created user with an _id property
|
||||
// Simulate created user with an _id property.
|
||||
return { _id: 'newUserId', ...userData };
|
||||
});
|
||||
updateUser.mockImplementation(async (id, userData) => {
|
||||
return { _id: id, ...userData };
|
||||
});
|
||||
|
||||
// For image download, simulate a successful response
|
||||
// For image download, simulate a successful response.
|
||||
const fakeBuffer = Buffer.from('fake image');
|
||||
const fakeResponse = {
|
||||
ok: true,
|
||||
@@ -144,18 +125,13 @@ describe('setupOpenId', () => {
|
||||
};
|
||||
fetch.mockResolvedValue(fakeResponse);
|
||||
|
||||
// Finally, call the setup function so that passport.use gets called
|
||||
// (Re)initialize the strategy with current env settings.
|
||||
await setupOpenId();
|
||||
});
|
||||
|
||||
it('should create a new user with correct username when username claim exists', async () => {
|
||||
// Arrange – our userinfo already has username 'flast'
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.username).toBe(userinfo.username);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -171,16 +147,10 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should use given_name as username when username claim is missing', async () => {
|
||||
// Arrange – remove username from userinfo
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.username;
|
||||
// Expect the username to be the given name (unchanged case)
|
||||
const expectUsername = userinfo.given_name;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.username).toBe(expectUsername);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: expectUsername }),
|
||||
@@ -190,16 +160,11 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should use email as username when username and given_name are missing', async () => {
|
||||
// Arrange – remove username and given_name
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.username;
|
||||
delete userinfo.given_name;
|
||||
const expectUsername = userinfo.email;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.username).toBe(expectUsername);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: expectUsername }),
|
||||
@@ -209,14 +174,10 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
|
||||
// Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used
|
||||
process.env.OPENID_USERNAME_CLAIM = 'sub';
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – username should equal the sub (converted as-is)
|
||||
await setupOpenId();
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.username).toBe(userinfo.sub);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: userinfo.sub }),
|
||||
@@ -226,31 +187,21 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should set the full name correctly when given_name and family_name exist', async () => {
|
||||
// Arrange
|
||||
const userinfo = { ...baseUserinfo };
|
||||
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.name).toBe(expectedFullName);
|
||||
});
|
||||
|
||||
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
|
||||
// Arrange – use the name claim as the full name
|
||||
process.env.OPENID_NAME_CLAIM = 'name';
|
||||
const userinfo = { ...baseUserinfo, name: 'Custom Name' };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
await setupOpenId();
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user.name).toBe('Custom Name');
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
@@ -265,13 +216,8 @@ describe('setupOpenId', () => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – updateUser should be called and the user object updated
|
||||
await validate(validTokenSet, userinfo);
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
existingUser._id,
|
||||
expect.objectContaining({
|
||||
@@ -284,43 +230,154 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
jwtDecode.mockReturnValue({ roles: ['SomeOtherRole'] });
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
const { user, details } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBe(false);
|
||||
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – verify that download was attempted and the avatar field was set via updateUser
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
|
||||
expect(user.avatar).toBe('/fake/path/to/avatar.png');
|
||||
});
|
||||
|
||||
it('should not attempt to download avatar if picture is not provided', async () => {
|
||||
// Arrange – remove picture
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.picture;
|
||||
|
||||
// Act
|
||||
await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – fetch should not be called and avatar should remain undefined or empty
|
||||
await validate(validTokenSet, userinfo);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
// Depending on your implementation, user.avatar may be undefined or an empty string.
|
||||
});
|
||||
|
||||
it('should fallback to userinfo roles if the id_token is invalid (missing a period)', async () => {
|
||||
const invalidTokenSet = { ...validTokenSet, id_token: 'invalidtoken' };
|
||||
const userinfo = { ...baseUserinfo, roles: ['requiredRole'] };
|
||||
const { user } = await validate(invalidTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle downloadImage failure gracefully and not set an avatar', async () => {
|
||||
fetch.mockRejectedValue(new Error('network error'));
|
||||
const userinfo = { ...baseUserinfo };
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
expect(user.avatar).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow login if no required role is specified', async () => {
|
||||
delete process.env.OPENID_REQUIRED_ROLE;
|
||||
delete process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
jwtDecode.mockReturnValue({});
|
||||
const userinfo = { ...baseUserinfo };
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use roles from userinfo when OPENID_REQUIRED_ROLE_SOURCE is set to "userinfo"', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'userinfo';
|
||||
jwtDecode.mockReturnValue({});
|
||||
const userinfo = { ...baseUserinfo, roles: ['requiredRole'] };
|
||||
await setupOpenId();
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should merge roles from both token and userinfo when OPENID_REQUIRED_ROLE_SOURCE is "both"', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'both';
|
||||
jwtDecode.mockReturnValue({ roles: ['extraRole'] });
|
||||
const userinfo = { ...baseUserinfo, roles: ['requiredRole'] };
|
||||
await setupOpenId();
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to userinfo roles when token decode fails and roleSource is "both"', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'both';
|
||||
jwtDecode.mockImplementation(() => {
|
||||
throw new Error('Decode error');
|
||||
});
|
||||
const userinfo = { ...baseUserinfo, roles: ['requiredRole'] };
|
||||
await setupOpenId();
|
||||
const { user } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should merge roles from both token and userinfo when token is invalid and roleSource is "both"', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'both';
|
||||
const invalidTokenSet = { ...validTokenSet, id_token: 'invalidtoken' };
|
||||
const userinfo = { ...baseUserinfo, roles: ['requiredRole'] };
|
||||
await setupOpenId();
|
||||
const { user } = await validate(invalidTokenSet, userinfo);
|
||||
expect(user).toBeDefined();
|
||||
expect(createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject login if merged roles from both token and userinfo do not include required role', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_SOURCE = 'both';
|
||||
jwtDecode.mockReturnValue({ roles: ['SomeOtherRole'] });
|
||||
const userinfo = { ...baseUserinfo, roles: ['AnotherRole'] };
|
||||
await setupOpenId();
|
||||
const { user, details } = await validate(validTokenSet, userinfo);
|
||||
expect(user).toBe(false);
|
||||
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
|
||||
});
|
||||
|
||||
it('should pass usePKCE true and set code_challenge_method in params when OPENID_USE_PKCE is "true"', async () => {
|
||||
process.env.OPENID_USE_PKCE = 'true';
|
||||
await setupOpenId();
|
||||
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
|
||||
expect(callOptions.usePKCE).toBe(true);
|
||||
expect(callOptions.params.code_challenge_method).toBe('S256');
|
||||
});
|
||||
|
||||
it('should pass usePKCE false and not set code_challenge_method in params when OPENID_USE_PKCE is "false"', async () => {
|
||||
process.env.OPENID_USE_PKCE = 'false';
|
||||
await setupOpenId();
|
||||
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
|
||||
expect(callOptions.usePKCE).toBe(false);
|
||||
expect(callOptions.params.code_challenge_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
|
||||
delete process.env.OPENID_USE_PKCE;
|
||||
await setupOpenId();
|
||||
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
|
||||
expect(callOptions.usePKCE).toBe(false);
|
||||
expect(callOptions.params.code_challenge_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set id_token_signed_response_alg if OPENID_SET_FIRST_SUPPORTED_ALGORITHM is enabled', async () => {
|
||||
process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM = 'true';
|
||||
// Override isEnabled so that it returns true.
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
isEnabled.mockReturnValue(true);
|
||||
await setupOpenId();
|
||||
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
|
||||
expect(callOptions.client.metadata.id_token_signed_response_alg).toBe('RS256');
|
||||
});
|
||||
|
||||
it('should use access token when OPENID_REQUIRED_ROLE_TOKEN_KIND is set to "access"', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'access';
|
||||
// Reinitialize strategy so that the new token kind is used.
|
||||
await setupOpenId();
|
||||
jwtDecode.mockClear();
|
||||
jwtDecode.mockReturnValue({ roles: ['requiredRole'] });
|
||||
const userinfo = { ...baseUserinfo };
|
||||
await validate(validTokenSet, userinfo);
|
||||
expect(jwtDecode).toHaveBeenCalledWith(validTokenSet.access_token);
|
||||
});
|
||||
|
||||
it('should use proxy agent if PROXY is provided', async () => {
|
||||
process.env.PROXY = 'http://fake-proxy.com';
|
||||
await setupOpenId();
|
||||
const { logger } = require('~/config');
|
||||
expect(logger.info).toHaveBeenCalledWith(`[openidStrategy] Using proxy: ${process.env.PROXY}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ 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({
|
||||
@@ -20,7 +19,6 @@ jest.mock('winston', () => {
|
||||
transports: {
|
||||
Console: jest.fn(),
|
||||
DailyRotateFile: jest.fn(),
|
||||
File: jest.fn(),
|
||||
},
|
||||
addColors: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,3 @@ 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';
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -142,7 +142,6 @@ export enum Panel {
|
||||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
model = 'model',
|
||||
version = 'version',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
@@ -536,7 +535,6 @@ export type NewConversationParams = {
|
||||
buildDefault?: boolean;
|
||||
keepLatestMessage?: boolean;
|
||||
keepAddedConvos?: boolean;
|
||||
disableParams?: boolean;
|
||||
};
|
||||
|
||||
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
||||
import type { TAuthContext } from '~/common';
|
||||
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
|
||||
import { ThemeContext, useLocalize } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TLoginFormProps = {
|
||||
onSubmit: (data: TLoginUser) => void;
|
||||
@@ -15,8 +14,6 @@ type TLoginFormProps = {
|
||||
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const {
|
||||
register,
|
||||
getValues,
|
||||
@@ -24,12 +21,9 @@ 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) {
|
||||
@@ -102,12 +96,20 @@ 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(/ \(.*$/, '')
|
||||
@@ -129,12 +131,20 @@ 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>
|
||||
@@ -149,29 +159,15 @@ 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"
|
||||
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"
|
||||
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
|
||||
"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
</button>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OpenIDIcon } from '~/components';
|
||||
|
||||
interface MultiTenantOpenIDProps {
|
||||
serverDomain: string;
|
||||
openidLabel: string;
|
||||
openidImageUrl: string;
|
||||
localize: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* When multi‑tenant mode is enabled (startupConfig.emailLoginEnabled === true),
|
||||
* we render a form for the user to enter their email. When submitted, we perform a GET
|
||||
* request (via redirect) to /oauth/openid with the email as a query parameter.
|
||||
* If, for some reason, no email is provided, we simply redirect to /oauth/openid.
|
||||
*/
|
||||
function MultiTenantOpenID({
|
||||
serverDomain,
|
||||
openidLabel,
|
||||
openidImageUrl,
|
||||
localize,
|
||||
}: MultiTenantOpenIDProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<{ email: string }>();
|
||||
|
||||
const onSubmit = (data: { email: string }) => {
|
||||
// If an email is provided, include it as a query parameter.
|
||||
// Otherwise, simply redirect without an email.
|
||||
const emailQuery =
|
||||
data.email && data.email.trim() !== ''
|
||||
? `?email=${encodeURIComponent(data.email)}`
|
||||
: '';
|
||||
window.location.href = `${serverDomain}/oauth/openid${emailQuery}`;
|
||||
};
|
||||
|
||||
const renderError = (fieldName: string) => {
|
||||
const errorMessage = errors[fieldName]?.message;
|
||||
return errorMessage ? (
|
||||
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
|
||||
{String(errorMessage)}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-2">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
autoComplete="email"
|
||||
aria-label={localize('com_auth_email')}
|
||||
{...register('email', {
|
||||
required: localize('com_auth_email_required'),
|
||||
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: localize('com_auth_email_pattern'),
|
||||
},
|
||||
})}
|
||||
aria-invalid={!!errors.email}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
placeholder=" "
|
||||
/>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="
|
||||
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
|
||||
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
>
|
||||
{localize('com_auth_email_address')}
|
||||
</label>
|
||||
</div>
|
||||
{renderError('email')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
|
||||
data-testid="openid"
|
||||
>
|
||||
{openidImageUrl ? (
|
||||
<img src={openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)}
|
||||
<p>{openidLabel}</p>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiTenantOpenID;
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import React, { useState } from 'react';
|
||||
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, ThemeContext } from '~/hooks';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
|
||||
const Registration: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const {
|
||||
@@ -26,15 +24,10 @@ 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: () => {
|
||||
@@ -76,13 +69,21 @@ 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>
|
||||
@@ -177,32 +178,17 @@ 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 ||
|
||||
isSubmitting ||
|
||||
(requireCaptcha && !turnstileToken)
|
||||
}
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
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>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
GoogleIcon,
|
||||
FacebookIcon,
|
||||
OpenIDIcon,
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
AppleIcon,
|
||||
} from '~/components';
|
||||
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
|
||||
|
||||
import SocialButton from './SocialButton';
|
||||
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { TStartupConfig } from 'librechat-data-provider';
|
||||
import MultiTenantOpenID from './MultiTenantOpenID';
|
||||
|
||||
function SocialLoginRender({
|
||||
startupConfig,
|
||||
@@ -79,37 +73,23 @@ function SocialLoginRender({
|
||||
id="apple"
|
||||
/>
|
||||
),
|
||||
openid:
|
||||
startupConfig.openidLoginEnabled &&
|
||||
(startupConfig.openidMultiTenantEnabled ? (
|
||||
<MultiTenantOpenID
|
||||
key="openid"
|
||||
openidImageUrl={startupConfig.openidImageUrl}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
openidLabel={startupConfig.openidLabel}
|
||||
localize={localize}
|
||||
/>
|
||||
) : (
|
||||
<SocialButton
|
||||
key="openid"
|
||||
enabled={startupConfig.openidLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="openid"
|
||||
Icon={() =>
|
||||
startupConfig.openidImageUrl ? (
|
||||
<img
|
||||
src={startupConfig.openidImageUrl}
|
||||
alt="OpenID Logo"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.openidLabel}
|
||||
id="openid"
|
||||
/>
|
||||
)),
|
||||
openid: startupConfig.openidLoginEnabled && (
|
||||
<SocialButton
|
||||
key="openid"
|
||||
enabled={startupConfig.openidLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="openid"
|
||||
Icon={() =>
|
||||
startupConfig.openidImageUrl ? (
|
||||
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.openidLabel}
|
||||
id="openid"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -206,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'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',
|
||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
centerFormOnLanding &&
|
||||
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||
!isSubmitting &&
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileIcon from '~/components/svg/Files/FileIcon';
|
||||
import { Spinner } from '~/components';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import SourceIcon from './SourceIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FilePreview = ({
|
||||
@@ -18,15 +19,28 @@ 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} />
|
||||
{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"
|
||||
{progress < 1 && (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
offset={offset}
|
||||
circleCSSProperties={circleCSSProperties}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -161,7 +161,7 @@ const ImagePreview = ({
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
|
||||
className={cn('w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto')}
|
||||
disableScroll={false}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -37,7 +36,6 @@ 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>[];
|
||||
@@ -62,14 +60,12 @@ 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,
|
||||
@@ -100,7 +96,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[], setFiles });
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
setRowSelection({});
|
||||
}}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
@@ -222,10 +218,13 @@ 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}/${
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function CancelledIcon() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary">
|
||||
<X className="size-4" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
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']) {
|
||||
@@ -29,6 +37,19 @@ 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)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { X, ArrowDownToLine } from 'lucide-react';
|
||||
import { Button, OGDialog, OGDialogContent } from '~/components';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) {
|
||||
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
|
||||
return (
|
||||
<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.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' }}
|
||||
>
|
||||
<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"
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export default function FinishedIcon() {
|
||||
return (
|
||||
<div
|
||||
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
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' }}
|
||||
data-projection-id="162"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import DialogImage from './DialogImage';
|
||||
import { Skeleton } from '~/components';
|
||||
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` };
|
||||
};
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
@@ -22,7 +41,6 @@ const Image = ({
|
||||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -38,63 +56,39 @@ 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 (
|
||||
<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.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,
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
<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 }} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
downloadImage={downloadImage}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoaded && <DialogImage src={imagePath} height={height} width={width} />}
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,13 +7,17 @@ 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 = {
|
||||
@@ -89,21 +93,8 @@ 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}
|
||||
/>
|
||||
);
|
||||
@@ -127,6 +118,7 @@ const Part = memo(
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
||||
@@ -1,82 +1,25 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { memo } 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: Partial<TAttachment> }) => {
|
||||
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath ?? '',
|
||||
filename: attachment.filename ?? '',
|
||||
href: attachment.filepath,
|
||||
filename: attachment.filename,
|
||||
});
|
||||
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]);
|
||||
const extension = attachment.filename.split('.').pop();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,60 +27,20 @@ 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 <ImageAttachment attachment={attachment} />;
|
||||
} else if (!attachment.filepath) {
|
||||
return null;
|
||||
return (
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import React, { useMemo, useState } 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 { AttachmentGroup } from './Attachment';
|
||||
import { CodeInProgress } from './CodeProgress';
|
||||
import Attachment from './Attachment';
|
||||
import Stdout from './Stdout';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface ParsedArgs {
|
||||
@@ -44,101 +45,46 @@ 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);
|
||||
|
||||
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]);
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<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)}
|
||||
@@ -148,71 +94,31 @@ export default function ExecuteCode({
|
||||
isExpanded={showCode}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
<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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as OpenAIImageGen } from './OpenAIImageGen';
|
||||
@@ -17,7 +17,7 @@ const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
|
||||
return (
|
||||
processedContent && (
|
||||
<pre className="shrink-0">
|
||||
<div className="text-text-primary">{processedContent}</div>
|
||||
<div>{processedContent}</div>
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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';
|
||||
@@ -1,8 +1,4 @@
|
||||
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 =
|
||||
@@ -14,7 +10,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 overflow-visible whitespace-nowrap"
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
@@ -28,7 +24,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
@@ -47,7 +43,6 @@ export default function ProgressText({
|
||||
hasInput = true,
|
||||
popover = false,
|
||||
isExpanded = false,
|
||||
error = false,
|
||||
}: {
|
||||
progress: number;
|
||||
onClick?: () => void;
|
||||
@@ -57,28 +52,33 @@ 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 w-full items-center gap-2',
|
||||
hasInput ? '' : 'pointer-events-none',
|
||||
)}
|
||||
className={cn('inline-flex items-center gap-1', hasInput ? '' : 'pointer-events-none')}
|
||||
disabled={!hasInput}
|
||||
onClick={hasInput ? onClick : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{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]" />
|
||||
))}
|
||||
{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>
|
||||
</button>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { ShieldCheck, TriangleAlert } from 'lucide-react';
|
||||
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import { useLocalize, useProgress } from '~/hooks';
|
||||
import { AttachmentGroup } from './Parts';
|
||||
import ToolCallInfo from './ToolCallInfo';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import InProgressCall from './InProgressCall';
|
||||
import Attachment from './Parts/Attachment';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import ProgressText from './ProgressText';
|
||||
import { Button } from '~/components';
|
||||
import { logger, cn } from '~/utils';
|
||||
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;
|
||||
|
||||
export default function ToolCall({
|
||||
initialProgress = 0.1,
|
||||
@@ -28,16 +37,11 @@ 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 {
|
||||
@@ -46,6 +50,7 @@ export default function ToolCall({
|
||||
isMCPToolCall: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [func, _domain] = name.includes(actionDelimiter)
|
||||
? name.split(actionDelimiter)
|
||||
: [name, ''];
|
||||
@@ -63,6 +68,7 @@ export default function ToolCall({
|
||||
if (typeof _args === 'string') {
|
||||
return _args;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(_args, null, 2);
|
||||
} catch (e) {
|
||||
@@ -92,8 +98,42 @@ export default function ToolCall({
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
const progress = useProgress(initialProgress);
|
||||
const progress = useProgress(error === true ? 1 : 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) {
|
||||
@@ -108,125 +148,51 @@ 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 (
|
||||
<>
|
||||
<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>
|
||||
<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',
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
{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>
|
||||
{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} />}
|
||||
</>
|
||||
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
71
client/src/components/Chat/Messages/Content/ToolPopover.tsx
Normal file
71
client/src/components/Chat/Messages/Content/ToolPopover.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const LoadingSpinner = memo(() => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
|
||||
<Spinner className="text-text-primary" />
|
||||
<Spinner className="h-4 w-4 text-text-primary" />
|
||||
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 'librechat-data-provider';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
|
||||
export default function AnthropicSettings({
|
||||
conversation,
|
||||
|
||||
@@ -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 'librechat-data-provider';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
|
||||
export default function BedrockSettings({
|
||||
conversation,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { getSettingsKeys } from 'librechat-data-provider';
|
||||
import type { SettingDefinition } from 'librechat-data-provider';
|
||||
import type { SettingDefinition, DynamicSettingProps } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { presetSettings } from 'librechat-data-provider';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
|
||||
export default function OpenAISettings({
|
||||
conversation,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
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 */}
|
||||
{localize('com_ui_action_button')}
|
||||
Action Button
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -19,25 +18,24 @@ 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 UploadFileButton from './UploadFileButton';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import store from '~/store';
|
||||
import ActionButton from '../ActionButton';
|
||||
import UploadFileButton from './UploadFileButton';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -59,14 +57,12 @@ 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,
|
||||
@@ -91,7 +87,7 @@ export default function DataTableFile<TData, TValue>({
|
||||
<>
|
||||
<div className="mt-2 flex flex-col items-start">
|
||||
<h2 className="text-lg">
|
||||
<strong>{localize('com_ui_files')}</strong>
|
||||
<strong>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">
|
||||
@@ -107,7 +103,7 @@ export default function DataTableFile<TData, TValue>({
|
||||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[], setFiles });
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
setRowSelection({});
|
||||
}}
|
||||
className="ml-1 gap-2 dark:hover:bg-gray-850/25 sm:ml-0"
|
||||
@@ -246,11 +242,13 @@ 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="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 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>
|
||||
<Button
|
||||
className="dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Tools, AuthType } from 'librechat-data-provider';
|
||||
import { TerminalSquareIcon } from 'lucide-react';
|
||||
import { TerminalSquareIcon, Loader } from 'lucide-react';
|
||||
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||
import type { CodeBarProps } from '~/common';
|
||||
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
|
||||
@@ -9,7 +9,6 @@ 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();
|
||||
@@ -92,7 +91,7 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
|
||||
disabled={execute.isLoading}
|
||||
>
|
||||
{execute.isLoading ? (
|
||||
<Spinner className="animate-spin" size={18} />
|
||||
<Loader className="animate-spin" size={18} />
|
||||
) : (
|
||||
<TerminalSquareIcon size={18} />
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
@@ -10,7 +11,6 @@ import DeleteButton from './DeleteButton';
|
||||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
@@ -50,13 +50,10 @@ export default function AgentFooter({
|
||||
return localize('com_ui_create');
|
||||
};
|
||||
|
||||
const showButtons = activePanel === Panel.builder;
|
||||
|
||||
return (
|
||||
<div className="mb-1 flex w-full flex-col gap-2">
|
||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{showButtons && agent_id && <VersionButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
|
||||
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DeleteButton
|
||||
@@ -66,13 +63,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
|
||||
|
||||
@@ -87,42 +87,7 @@ export default function AgentPanel({
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error & {
|
||||
statusCode?: number;
|
||||
details?: { duplicateVersion?: any; versionIndex?: number };
|
||||
response?: { status?: number; data?: any };
|
||||
};
|
||||
|
||||
const isDuplicateVersionError =
|
||||
(error.statusCode === 409 && error.details?.duplicateVersion) ||
|
||||
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
let versionIndex: number | undefined = undefined;
|
||||
|
||||
if (error.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.details.versionIndex;
|
||||
} else if (error.response?.data?.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.response.data.details.versionIndex;
|
||||
}
|
||||
|
||||
if (versionIndex === undefined || versionIndex < 0) {
|
||||
showToast({
|
||||
message: localize('com_agents_update_error'),
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
@@ -255,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="mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<AgentSelect
|
||||
createMutation={create}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { ActionsEndpoint } from '~/common';
|
||||
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
|
||||
import type { Action, TConfig, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import AgentPanel from './AgentPanel';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanelSwitch() {
|
||||
@@ -16,19 +15,11 @@ export default function AgentPanelSwitch() {
|
||||
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
||||
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const createMutation = useCreateAgentMutation();
|
||||
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
...(config as TConfig),
|
||||
capabilities: Array.isArray(config.capabilities)
|
||||
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||
: ([] as AgentCapabilities[]),
|
||||
} as TAgentsEndpoint;
|
||||
}, [endpointsConfig]);
|
||||
const agentsConfig = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents] ?? ({} as TConfig | null),
|
||||
[endpointsConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const agent_id = conversation?.agent_id ?? '';
|
||||
@@ -50,23 +41,12 @@ export default function AgentPanelSwitch() {
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id: currentAgentId,
|
||||
createMutation,
|
||||
};
|
||||
|
||||
if (activePanel === Panel.actions) {
|
||||
return <ActionsPanel {...commonProps} />;
|
||||
}
|
||||
|
||||
if (activePanel === Panel.version) {
|
||||
return (
|
||||
<VersionPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agentsConfig={agentsConfig}
|
||||
selectedAgentId={currentAgentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
||||
);
|
||||
|
||||
@@ -105,7 +105,7 @@ export function AvatarMenu({
|
||||
>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
|
||||
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"
|
||||
tabIndex={-1}
|
||||
data-orientation="vertical"
|
||||
onClick={onItemClick}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { useFormContext, useWatch, Controller } from 'react-hook-form';
|
||||
import {
|
||||
getSettingsKeys,
|
||||
alternateName,
|
||||
agentParamSettings,
|
||||
SettingDefinition,
|
||||
} from 'librechat-data-provider';
|
||||
import { getSettingsKeys, alternateName } 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,
|
||||
@@ -57,7 +52,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 ?? [];
|
||||
@@ -68,18 +63,10 @@ export default function ModelPanel({
|
||||
[provider, endpointsConfig],
|
||||
);
|
||||
|
||||
const parameters = useMemo((): SettingDefinition[] => {
|
||||
const customParams = endpointsConfig[provider]?.customParams ?? {};
|
||||
const parameters = useMemo(() => {
|
||||
const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? '');
|
||||
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]);
|
||||
return agentSettings[combinedKey] ?? agentSettings[endpointKey];
|
||||
}, [endpointType, model, provider]);
|
||||
|
||||
const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => {
|
||||
setValue(`model_parameters.${optionKey}`, value);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { History } from 'lucide-react';
|
||||
import { Panel } from '~/common';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VersionButtonProps {
|
||||
setActivePanel: (panel: Panel) => void;
|
||||
}
|
||||
|
||||
const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||
onClick={() => setActivePanel(Panel.version)}
|
||||
>
|
||||
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_agent_version')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VersionButton;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import VersionItem from './VersionItem';
|
||||
import { VersionContext } from './VersionPanel';
|
||||
|
||||
type VersionContentProps = {
|
||||
selectedAgentId: string;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
versionContext: VersionContext;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionContent({
|
||||
selectedAgentId,
|
||||
isLoading,
|
||||
error,
|
||||
versionContext,
|
||||
onRestore,
|
||||
}: VersionContentProps) {
|
||||
const { versions, versionIds } = versionContext;
|
||||
const localize = useLocalize();
|
||||
|
||||
if (!selectedAgentId) {
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_no_agent')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center text-red-500">{localize('com_ui_agent_version_error')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versionIds.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{versionIds.map(({ id, version, isActive }) => (
|
||||
<VersionItem
|
||||
key={id}
|
||||
version={version}
|
||||
index={id}
|
||||
isActive={isActive}
|
||||
versionsLength={versions.length}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { VersionRecord } from './VersionPanel';
|
||||
|
||||
type VersionItemProps = {
|
||||
version: VersionRecord;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
versionsLength: number;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionItem({
|
||||
version,
|
||||
index,
|
||||
isActive,
|
||||
versionsLength,
|
||||
onRestore,
|
||||
}: VersionItemProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getVersionTimestamp = (version: VersionRecord): string => {
|
||||
const timestamp = version.updatedAt || version.createdAt;
|
||||
|
||||
if (timestamp) {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime()) || date.toString() === 'Invalid Date') {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
return date.toLocaleString();
|
||||
} catch (error) {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
}
|
||||
|
||||
return localize('com_ui_agent_version_no_date');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border-light p-3">
|
||||
<div className="flex items-center justify-between font-medium">
|
||||
<span>
|
||||
{localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full border border-green-600 bg-green-600/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-500 dark:bg-green-500/30 dark:text-green-300">
|
||||
{localize('com_ui_agent_version_active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{getVersionTimestamp(version)}</div>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={() => {
|
||||
if (window.confirm(localize('com_ui_agent_version_restore_confirm'))) {
|
||||
onRestore(index);
|
||||
}
|
||||
}}
|
||||
aria-label={localize('com_ui_agent_version_restore')}
|
||||
>
|
||||
{localize('com_ui_agent_version_restore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AgentPanelProps } from '~/common';
|
||||
import { Panel } from '~/common';
|
||||
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||
import { useLocalize, useToast } from '~/hooks';
|
||||
import VersionContent from './VersionContent';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
export type AgentState = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
} | null;
|
||||
|
||||
export type VersionWithId = {
|
||||
id: number;
|
||||
originalIndex: number;
|
||||
version: VersionRecord;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VersionContext = {
|
||||
versions: VersionRecord[];
|
||||
versionIds: VersionWithId[];
|
||||
currentAgent: AgentState;
|
||||
selectedAgentId: string;
|
||||
activeVersion: VersionRecord | null;
|
||||
};
|
||||
|
||||
export interface AgentWithVersions extends Agent {
|
||||
capabilities?: string[];
|
||||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
|
||||
export type VersionPanelProps = {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
setActivePanel: AgentPanelProps['setActivePanel'];
|
||||
selectedAgentId?: string;
|
||||
};
|
||||
|
||||
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
data: agent,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetAgentByIdQuery(selectedAgentId, {
|
||||
enabled: !!selectedAgentId && selectedAgentId !== '',
|
||||
});
|
||||
|
||||
const revertAgentVersion = useRevertAgentVersionMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_success'),
|
||||
status: 'success',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const agentWithVersions = agent as AgentWithVersions;
|
||||
|
||||
const currentAgent = useMemo(() => {
|
||||
if (!agentWithVersions) return null;
|
||||
return {
|
||||
name: agentWithVersions.name,
|
||||
description: agentWithVersions.description,
|
||||
instructions: agentWithVersions.instructions,
|
||||
artifacts: agentWithVersions.artifacts,
|
||||
capabilities: agentWithVersions.capabilities,
|
||||
tools: agentWithVersions.tools,
|
||||
};
|
||||
}, [agentWithVersions]);
|
||||
|
||||
const versions = useMemo(() => {
|
||||
const versionsCopy = [...(agentWithVersions?.versions || [])];
|
||||
return versionsCopy.sort((a, b) => {
|
||||
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [agentWithVersions?.versions]);
|
||||
|
||||
const activeVersion = useMemo(() => {
|
||||
return versions.length > 0
|
||||
? versions.find((v) => isActiveVersion(v, currentAgent, versions)) || null
|
||||
: null;
|
||||
}, [versions, currentAgent]);
|
||||
|
||||
const versionIds = useMemo(() => {
|
||||
if (versions.length === 0) return [];
|
||||
|
||||
const matchingVersions = versions.filter((v) => isActiveVersion(v, currentAgent, versions));
|
||||
|
||||
const activeVersionId =
|
||||
matchingVersions.length > 0 ? versions.findIndex((v) => v === matchingVersions[0]) : -1;
|
||||
|
||||
return versions.map((version, displayIndex) => {
|
||||
const originalIndex =
|
||||
agentWithVersions?.versions?.findIndex(
|
||||
(v) =>
|
||||
v.updatedAt === version.updatedAt &&
|
||||
v.createdAt === version.createdAt &&
|
||||
v.name === version.name,
|
||||
) ?? displayIndex;
|
||||
|
||||
return {
|
||||
id: displayIndex,
|
||||
originalIndex,
|
||||
version,
|
||||
isActive: displayIndex === activeVersionId,
|
||||
};
|
||||
});
|
||||
}, [versions, currentAgent, agentWithVersions?.versions]);
|
||||
|
||||
const versionContext: VersionContext = useMemo(
|
||||
() => ({
|
||||
versions,
|
||||
versionIds,
|
||||
currentAgent,
|
||||
selectedAgentId,
|
||||
activeVersion,
|
||||
}),
|
||||
[versions, versionIds, currentAgent, selectedAgentId, activeVersion],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
(displayIndex: number) => {
|
||||
const versionWithId = versionIds.find((v) => v.id === displayIndex);
|
||||
|
||||
if (versionWithId) {
|
||||
const originalIndex = versionWithId.originalIndex;
|
||||
|
||||
revertAgentVersion.mutate({
|
||||
agent_id: selectedAgentId,
|
||||
version_index: originalIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[revertAgentVersion, selectedAgentId, versionIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
|
||||
<div className="version-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||
<div className="absolute left-0 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
}}
|
||||
>
|
||||
<div className="version-panel-content flex w-full items-center justify-center gap-2">
|
||||
<ChevronLeft />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 mt-2 text-xl font-medium">
|
||||
{localize('com_ui_agent_version_history')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<VersionContent
|
||||
selectedAgentId={selectedAgentId}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
versionContext={versionContext}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import VersionContent from '../VersionContent';
|
||||
import { VersionContext } from '../VersionPanel';
|
||||
|
||||
const mockRestore = 'Restore';
|
||||
|
||||
jest.mock('../VersionItem', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ version, isActive, onRestore, index }) => (
|
||||
<div data-testid="version-item">
|
||||
<div>{version.name}</div>
|
||||
{!isActive && (
|
||||
<button data-testid={`restore-button-${index}`} onClick={() => onRestore(index)}>
|
||||
{mockRestore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_no_agent: 'No agent selected',
|
||||
com_ui_agent_version_error: 'Error loading versions',
|
||||
com_ui_agent_version_empty: 'No versions available',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/svg', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
const mockVersionItem = jest.requireMock('../VersionItem').default;
|
||||
|
||||
describe('VersionContent', () => {
|
||||
const mockVersionIds = [
|
||||
{ id: 0, version: { name: 'First' }, isActive: true, originalIndex: 2 },
|
||||
{ id: 1, version: { name: 'Second' }, isActive: false, originalIndex: 1 },
|
||||
{ id: 2, version: { name: 'Third' }, isActive: false, originalIndex: 0 },
|
||||
];
|
||||
|
||||
const mockContext: VersionContext = {
|
||||
versions: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }],
|
||||
versionIds: mockVersionIds,
|
||||
currentAgent: { name: 'Test Agent', description: null, instructions: null },
|
||||
selectedAgentId: 'agent-123',
|
||||
activeVersion: { name: 'First' },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: mockContext,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn(() => true);
|
||||
});
|
||||
|
||||
test('renders different UI states correctly', () => {
|
||||
const renderTest = (props) => {
|
||||
const result = render(<VersionContent {...defaultProps} {...props} />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const { getByTestId, unmount: unmount1 } = renderTest({ isLoading: true });
|
||||
expect(getByTestId('spinner')).toBeInTheDocument();
|
||||
unmount1();
|
||||
|
||||
const { getByText: getText1, unmount: unmount2 } = renderTest({
|
||||
error: new Error('Test error'),
|
||||
});
|
||||
expect(getText1('Error loading versions')).toBeInTheDocument();
|
||||
unmount2();
|
||||
|
||||
const { getByText: getText2, unmount: unmount3 } = renderTest({ selectedAgentId: '' });
|
||||
expect(getText2('No agent selected')).toBeInTheDocument();
|
||||
unmount3();
|
||||
|
||||
const emptyContext = { ...mockContext, versions: [], versionIds: [] };
|
||||
const { getByText: getText3, unmount: unmount4 } = renderTest({ versionContext: emptyContext });
|
||||
expect(getText3('No versions available')).toBeInTheDocument();
|
||||
unmount4();
|
||||
|
||||
mockVersionItem.mockClear();
|
||||
|
||||
const { getAllByTestId } = renderTest({});
|
||||
expect(getAllByTestId('version-item')).toHaveLength(3);
|
||||
expect(mockVersionItem).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('restore functionality works correctly', () => {
|
||||
const onRestoreMock = jest.fn();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<VersionContent {...defaultProps} onRestore={onRestoreMock} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('restore-button-1'));
|
||||
expect(onRestoreMock).toHaveBeenCalledWith(1);
|
||||
|
||||
expect(queryByTestId('restore-button-0')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-1')).toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles edge cases in data', () => {
|
||||
const { getAllByTestId, getByText, queryByTestId, queryByText, rerender } = render(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versions: [] }} />,
|
||||
);
|
||||
expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length);
|
||||
|
||||
rerender(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versionIds: [] }} />,
|
||||
);
|
||||
expect(getByText('No versions available')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<VersionContent
|
||||
{...defaultProps}
|
||||
selectedAgentId=""
|
||||
isLoading={true}
|
||||
error={new Error('Test')}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('No agent selected')).toBeInTheDocument();
|
||||
expect(queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<VersionContent {...defaultProps} isLoading={true} error={new Error('Test')} />);
|
||||
expect(queryByTestId('spinner')).toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import VersionItem from '../VersionItem';
|
||||
import { VersionRecord } from '../VersionPanel';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key, params) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_title: params?.versionNumber
|
||||
? `Version ${params.versionNumber}`
|
||||
: 'Version',
|
||||
com_ui_agent_version_active: 'Active Version',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_unknown_date: 'Unknown date',
|
||||
com_ui_agent_version_no_date: 'No date',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('VersionItem', () => {
|
||||
const mockVersion: VersionRecord = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
version: mockVersion,
|
||||
index: 1,
|
||||
isActive: false,
|
||||
versionsLength: 3,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn().mockImplementation(() => true);
|
||||
});
|
||||
|
||||
test('renders version number and timestamp', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
expect(screen.getByText('Version 2')).toBeInTheDocument();
|
||||
const date = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(date)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('active version badge and no restore button when active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={true} />);
|
||||
expect(screen.getByText('Active Version')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore button and no active badge when not active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={false} />);
|
||||
expect(screen.queryByText('Active Version')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore confirmation flow - confirmed', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to restore this version?');
|
||||
expect(defaultProps.onRestore).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test('restore confirmation flow - canceled', () => {
|
||||
window.confirm = jest.fn().mockImplementation(() => false);
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(defaultProps.onRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles invalid timestamp', () => {
|
||||
render(
|
||||
<VersionItem {...defaultProps} version={{ ...mockVersion, updatedAt: 'invalid-date' }} />,
|
||||
);
|
||||
expect(screen.getByText('Unknown date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles missing timestamps', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{ ...mockVersion, updatedAt: undefined, createdAt: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('prefers updatedAt over createdAt when both exist', () => {
|
||||
const versionWithBothDates = {
|
||||
...mockVersion,
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
render(<VersionItem {...defaultProps} version={versionWithBothDates} />);
|
||||
const updatedDate = new Date('2023-01-02T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(updatedDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to createdAt when updatedAt is missing', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{
|
||||
...mockVersion,
|
||||
updatedAt: undefined,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(createdDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles empty version object', () => {
|
||||
render(<VersionItem {...defaultProps} version={{}} />);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Panel } from '~/common/types';
|
||||
import VersionContent from '../VersionContent';
|
||||
import VersionPanel from '../VersionPanel';
|
||||
|
||||
const mockAgentData = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Version 1',
|
||||
description: 'Description 1',
|
||||
instructions: 'Instructions 1',
|
||||
tools: ['tool1'],
|
||||
capabilities: ['capability1'],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'Version 2',
|
||||
description: 'Description 2',
|
||||
instructions: 'Instructions 2',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetAgentByIdQuery: jest.fn(() => ({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
useRevertAgentVersionMutation: jest.fn(() => ({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../VersionContent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-content" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => key),
|
||||
useToast: jest.fn(() => ({ showToast: jest.fn() })),
|
||||
}));
|
||||
|
||||
describe('VersionPanel', () => {
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const defaultProps = {
|
||||
agentsConfig: null,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
selectedAgentId: 'agent-123',
|
||||
};
|
||||
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('renders panel UI and handles navigation', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-content')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(mockSetActivePanel).toHaveBeenCalledWith(Panel.builder);
|
||||
});
|
||||
|
||||
test('VersionContent receives correct props', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.any(Object),
|
||||
versions: expect.any(Array),
|
||||
versionIds: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles data state variations', () => {
|
||||
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedAgentId: '' }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
versions: [],
|
||||
versionIds: [],
|
||||
currentAgent: null,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: { ...mockAgentData, versions: undefined },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({ versions: [] }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isLoading: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const testError = new Error('Test error');
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: testError,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: testError }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('memoizes agent data correctly', () => {
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.objectContaining({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
}),
|
||||
versions: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Version 2' }),
|
||||
expect.objectContaining({ name: 'Version 1' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { isActiveVersion } from '../isActiveVersion';
|
||||
import type { AgentState, VersionRecord } from '../VersionPanel';
|
||||
|
||||
describe('isActiveVersion', () => {
|
||||
const createVersion = (overrides = {}): VersionRecord => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAgentState = (overrides = {}): AgentState => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('returns true for the first version in versions array when currentAgent is null', () => {
|
||||
const versions = [
|
||||
createVersion({ name: 'First Version' }),
|
||||
createVersion({ name: 'Second Version' }),
|
||||
];
|
||||
|
||||
expect(isActiveVersion(versions[0], null, versions)).toBe(true);
|
||||
expect(isActiveVersion(versions[1], null, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when all fields match exactly', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when names do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ name: 'Different Name' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when descriptions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ description: 'Different Description' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when instructions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ instructions: 'Different Instructions' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when artifacts do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: 'different_artifacts' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches tools regardless of order', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when tools arrays have different lengths', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2', 'tool3'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when tools do not match', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches capabilities regardless of order', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability2', 'capability1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when capabilities arrays have different lengths', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({
|
||||
capabilities: ['capability1', 'capability2', 'capability3'],
|
||||
});
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when capabilities do not match', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles missing tools arrays', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has tools but agent does not', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has tools but version does not', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles missing capabilities arrays', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has capabilities but agent does not', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has capabilities but version does not', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'capability2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles null values in fields', () => {
|
||||
const version = createVersion({ name: null });
|
||||
const currentAgent = createAgentState({ name: null });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty versions array', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty arrays for tools', () => {
|
||||
const version = createVersion({ tools: [] });
|
||||
const currentAgent = createAgentState({ tools: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty arrays for capabilities', () => {
|
||||
const version = createVersion({ capabilities: [] });
|
||||
const currentAgent = createAgentState({ capabilities: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles missing artifacts field', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has artifacts but agent does not', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has artifacts but version does not', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty string for artifacts', () => {
|
||||
const version = createVersion({ artifacts: '' });
|
||||
const currentAgent = createAgentState({ artifacts: '' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { AgentState, VersionRecord } from './VersionPanel';
|
||||
|
||||
export const isActiveVersion = (
|
||||
version: VersionRecord,
|
||||
currentAgent: AgentState,
|
||||
versions: VersionRecord[],
|
||||
): boolean => {
|
||||
if (!versions || versions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentAgent) {
|
||||
const versionIndex = versions.findIndex(
|
||||
(v) =>
|
||||
v.name === version.name &&
|
||||
v.instructions === version.instructions &&
|
||||
v.artifacts === version.artifacts,
|
||||
);
|
||||
return versionIndex === 0;
|
||||
}
|
||||
|
||||
const matchesName = version.name === currentAgent.name;
|
||||
const matchesDescription = version.description === currentAgent.description;
|
||||
const matchesInstructions = version.instructions === currentAgent.instructions;
|
||||
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
|
||||
|
||||
const toolsMatch = () => {
|
||||
if (!version.tools && !currentAgent.tools) return true;
|
||||
if (!version.tools || !currentAgent.tools) return false;
|
||||
if (version.tools.length !== currentAgent.tools.length) return false;
|
||||
|
||||
const sortedVersionTools = [...version.tools].sort();
|
||||
const sortedCurrentTools = [...currentAgent.tools].sort();
|
||||
|
||||
return sortedVersionTools.every((tool, i) => tool === sortedCurrentTools[i]);
|
||||
};
|
||||
|
||||
const capabilitiesMatch = () => {
|
||||
if (!version.capabilities && !currentAgent.capabilities) return true;
|
||||
if (!version.capabilities || !currentAgent.capabilities) return false;
|
||||
if (version.capabilities.length !== currentAgent.capabilities.length) return false;
|
||||
|
||||
const sortedVersionCapabilities = [...version.capabilities].sort();
|
||||
const sortedCurrentCapabilities = [...currentAgent.capabilities].sort();
|
||||
|
||||
return sortedVersionCapabilities.every(
|
||||
(capability, i) => capability === sortedCurrentCapabilities[i],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
matchesName &&
|
||||
matchesDescription &&
|
||||
matchesInstructions &&
|
||||
matchesArtifacts &&
|
||||
toolsMatch() &&
|
||||
capabilitiesMatch()
|
||||
);
|
||||
};
|
||||
@@ -1,271 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import AgentFooter from '../AgentFooter';
|
||||
import { Panel } from '~/common';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import * as reactHookForm from 'react-hook-form';
|
||||
import * as hooks from '~/hooks';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
control: {},
|
||||
}),
|
||||
useWatch: () => {
|
||||
return {
|
||||
agent: {
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
},
|
||||
id: 'agent-123',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: '',
|
||||
role: 'USER',
|
||||
provider: 'local',
|
||||
emailVerified: true,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
} as TUser;
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key) => {
|
||||
const translations = {
|
||||
com_ui_save: 'Save',
|
||||
com_ui_create: 'Create',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useAuthContext: () => ({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
}),
|
||||
useHasAccess: () => true,
|
||||
}));
|
||||
|
||||
const createBaseMutation = <T = Agent, P = any>(
|
||||
isLoading = false,
|
||||
): UseMutationResult<T, Error, P> => {
|
||||
if (isLoading) {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: false as const,
|
||||
status: 'loading' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: true as const,
|
||||
status: 'idle' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useUpdateAgentMutation: () => createBaseMutation<Agent, any>(),
|
||||
}));
|
||||
|
||||
jest.mock('../Advanced/AdvancedButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="advanced-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../Version/VersionButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../AdminSettings', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="admin-settings" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DeleteButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="share-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DuplicateAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="duplicate-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/components', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
describe('AgentFooter', () => {
|
||||
const mockUsers = {
|
||||
regular: mockUser,
|
||||
admin: {
|
||||
...mockUser,
|
||||
id: 'admin-123',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: SystemRoles.ADMIN,
|
||||
} as TUser,
|
||||
different: {
|
||||
...mockUser,
|
||||
id: 'different-user',
|
||||
username: 'different',
|
||||
email: 'different@example.com',
|
||||
name: 'Different User',
|
||||
} as TUser,
|
||||
};
|
||||
|
||||
const createAuthContext = (user: TUser) => ({
|
||||
user,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
});
|
||||
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const mockSetCurrentAgentId = jest.fn();
|
||||
const mockCreateMutation = createBaseMutation<Agent, AgentCreateParams>();
|
||||
const mockUpdateMutation = createBaseMutation<Agent, any>();
|
||||
|
||||
const defaultProps = {
|
||||
activePanel: Panel.builder,
|
||||
createMutation: mockCreateMutation,
|
||||
updateMutation: mockUpdateMutation,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
setCurrentAgentId: mockSetCurrentAgentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Main Functionality', () => {
|
||||
test('renders with standard components based on default state', () => {
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('advanced-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles loading states for createMutation', () => {
|
||||
const { unmount } = render(
|
||||
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('handles loading states for updateMutation', () => {
|
||||
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
test('adjusts UI based on activePanel state', () => {
|
||||
render(<AgentFooter {...defaultProps} activePanel={Panel.advanced} />);
|
||||
expect(screen.queryByTestId('advanced-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on agent ID existence', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: { name: 'Test Agent', author: 'user-123' },
|
||||
id: undefined,
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on user role', () => {
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on permissions', () => {
|
||||
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles null agent data', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: null,
|
||||
id: 'agent-123',
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } 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, useDebouncedInput, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
@@ -23,20 +23,23 @@ function DynamicCheckbox({
|
||||
}: DynamicSettingProps) {
|
||||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<boolean>({
|
||||
optionKey: settingKey,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
});
|
||||
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
|
||||
|
||||
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, settingKey]);
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
setInputValue(checked);
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(checked);
|
||||
return;
|
||||
}
|
||||
setOption(settingKey)(checked);
|
||||
};
|
||||
|
||||
@@ -46,7 +49,8 @@ function DynamicCheckbox({
|
||||
defaultValue,
|
||||
conversation,
|
||||
inputValue,
|
||||
setInputValue: setLocalValue,
|
||||
setInputValue,
|
||||
preventDelayedUpdate: true,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -15,6 +16,7 @@ function DynamicCombobox({
|
||||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
options: _options,
|
||||
items: _items,
|
||||
showLabel = true,
|
||||
@@ -34,8 +36,11 @@ function DynamicCombobox({
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
return inputValue;
|
||||
}
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, settingKey]);
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (_items != null) {
|
||||
@@ -49,10 +54,13 @@ function DynamicCombobox({
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setInputValue(value);
|
||||
setOption(settingKey)(value);
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
setInputValue(value);
|
||||
} else {
|
||||
setOption(settingKey)(value);
|
||||
}
|
||||
},
|
||||
[setOption, settingKey],
|
||||
[optionType, setOption, settingKey],
|
||||
);
|
||||
|
||||
useParameterEffects({
|
||||
|
||||
@@ -12,6 +12,7 @@ function DynamicInput({
|
||||
settingKey,
|
||||
defaultValue,
|
||||
description = '',
|
||||
type = 'string',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
@@ -27,7 +28,7 @@ function DynamicInput({
|
||||
const { preset } = useChatContext();
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
|
||||
optionKey: settingKey,
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
@@ -43,7 +44,17 @@ function DynamicInput({
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e, !isNaN(Number(e.target.value)));
|
||||
const value = e.target.value;
|
||||
if (type !== 'number') {
|
||||
setInputValue(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
setInputValue(e);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
setInputValue(e, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ function DynamicSlider({
|
||||
);
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
|
||||
optionKey: settingKey,
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
@@ -13,6 +14,7 @@ function DynamicSwitch({
|
||||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
readonly = false,
|
||||
showDefault = false,
|
||||
labelCode = false,
|
||||
@@ -32,10 +34,21 @@ function DynamicSwitch({
|
||||
preventDelayedUpdate: true,
|
||||
});
|
||||
|
||||
const selectedValue = conversation?.[settingKey] ?? defaultValue;
|
||||
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 handleCheckedChange = (checked: boolean) => {
|
||||
setInputValue(checked);
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(checked);
|
||||
return;
|
||||
}
|
||||
setOption(settingKey)(checked);
|
||||
};
|
||||
|
||||
@@ -52,7 +65,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')}:{' '}
|
||||
@@ -71,11 +84,7 @@ function DynamicSwitch({
|
||||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 } from '~/utils';
|
||||
import { cn, defaultTextProps } from '~/utils';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
@@ -14,6 +15,7 @@ function DynamicTags({
|
||||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
placeholder = '',
|
||||
readonly = false,
|
||||
showDefault = false,
|
||||
@@ -36,10 +38,14 @@ function DynamicTags({
|
||||
|
||||
const updateState = useCallback(
|
||||
(update: string[]) => {
|
||||
setTags(update);
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setTags(update);
|
||||
return;
|
||||
}
|
||||
setOption(settingKey)(update);
|
||||
},
|
||||
[setOption, settingKey],
|
||||
[optionType, setOption, settingKey],
|
||||
);
|
||||
|
||||
const onTagClick = useCallback(() => {
|
||||
@@ -48,10 +54,18 @@ function DynamicTags({
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const currentValue = conversation?.[settingKey];
|
||||
const currentTags = useMemo(() => {
|
||||
return currentValue ?? defaultValue ?? [];
|
||||
}, [currentValue, defaultValue]);
|
||||
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 onTagRemove = useCallback(
|
||||
(indexToRemove: number) => {
|
||||
@@ -61,7 +75,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;
|
||||
@@ -112,7 +126,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">
|
||||
(
|
||||
@@ -160,11 +174,7 @@ 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>
|
||||
@@ -172,11 +182,7 @@ 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 } from '~/utils';
|
||||
import { cn, defaultTextProps } 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: settingKey,
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
initialValue:
|
||||
optionType !== OptionTypes.Custom
|
||||
? (conversation?.[settingKey] as string)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
excludedKeys,
|
||||
getSettingsKeys,
|
||||
tConvoUpdateSchema,
|
||||
paramSettings,
|
||||
SettingDefinition,
|
||||
} from 'librechat-data-provider';
|
||||
import { excludedKeys, getSettingsKeys, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { SaveAsPresetDialog } from '~/components/Endpoints';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
@@ -14,7 +8,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField, logger } from '~/utils';
|
||||
import { componentMapping } from './components';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import keyBy from 'lodash/keyBy';
|
||||
import { settings } from './settings';
|
||||
|
||||
export default function Parameters() {
|
||||
const localize = useLocalize();
|
||||
@@ -24,9 +18,7 @@ export default function Parameters() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [preset, setPreset] = useState<TPreset | null>(null);
|
||||
|
||||
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
|
||||
const provider = conversation?.endpoint ?? '';
|
||||
const model = conversation?.model ?? '';
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const bedrockRegions = useMemo(() => {
|
||||
return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? [];
|
||||
@@ -37,17 +29,13 @@ export default function Parameters() {
|
||||
[conversation?.endpoint, endpointsConfig],
|
||||
);
|
||||
|
||||
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,
|
||||
const parameters = useMemo(() => {
|
||||
const [combinedKey, endpointKey] = getSettingsKeys(
|
||||
endpointType ?? conversation?.endpoint ?? '',
|
||||
conversation?.model ?? '',
|
||||
);
|
||||
}, [endpointType, endpointsConfig, model, provider]);
|
||||
return settings[combinedKey] ?? settings[endpointKey];
|
||||
}, [conversation, endpointType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parameters) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
ReasoningEffort,
|
||||
BedrockProviders,
|
||||
anthropicSettings,
|
||||
} from './types';
|
||||
import { SettingDefinition, SettingsConfiguration } from './generate';
|
||||
} from 'librechat-data-provider';
|
||||
import type { SettingsConfiguration, SettingDefinition } from 'librechat-data-provider';
|
||||
|
||||
// Base definitions
|
||||
const baseDefinitions: Record<string, SettingDefinition> = {
|
||||
@@ -654,7 +654,7 @@ const bedrockGeneralCol2: SettingsConfiguration = [
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
export const paramSettings: Record<string, SettingsConfiguration | undefined> = {
|
||||
export const settings: Record<string, SettingsConfiguration | undefined> = {
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.azureOpenAI]: openAI,
|
||||
[EModelEndpoint.custom]: openAI,
|
||||
@@ -682,9 +682,9 @@ const bedrockGeneralColumns = {
|
||||
export const presetSettings: Record<
|
||||
string,
|
||||
| {
|
||||
col1: SettingsConfiguration;
|
||||
col2: SettingsConfiguration;
|
||||
}
|
||||
col1: SettingsConfiguration;
|
||||
col2: SettingsConfiguration;
|
||||
}
|
||||
| undefined
|
||||
> = {
|
||||
[EModelEndpoint.openAI]: openAIColumns,
|
||||
@@ -716,11 +716,11 @@ export const presetSettings: Record<
|
||||
},
|
||||
};
|
||||
|
||||
export const agentParamSettings: Record<string, SettingsConfiguration | undefined> = Object.entries(
|
||||
export const agentSettings: Record<string, SettingsConfiguration | undefined> = Object.entries(
|
||||
presetSettings,
|
||||
).reduce<Record<string, SettingsConfiguration | undefined>>((acc, [key, value]) => {
|
||||
).reduce((acc, [key, value]) => {
|
||||
if (value) {
|
||||
acc[key] = value.col2;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, {});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user