Compare commits
61 Commits
style/mark
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9fb86ba8 | ||
|
|
6137a089b3 | ||
|
|
a928115a84 | ||
|
|
1b14721e75 | ||
|
|
f4301e3fb0 | ||
|
|
fc71c6b358 | ||
|
|
3acb000090 | ||
|
|
f6924567ce | ||
|
|
1fa055b4ec | ||
|
|
0e18faf44b | ||
|
|
74474815aa | ||
|
|
5bec40e574 | ||
|
|
71a3d97058 | ||
|
|
a21891f692 | ||
|
|
cbc38b8263 | ||
|
|
83e2766187 | ||
|
|
8d6110342f | ||
|
|
472c2f14e4 | ||
|
|
8d51f450e8 | ||
|
|
5a2eb74c2d | ||
|
|
fc8e8d2a3b | ||
|
|
d653e96209 | ||
|
|
6bb6d2044b | ||
|
|
76d75030b9 | ||
|
|
abc32e66ce | ||
|
|
2ee68f9e4a | ||
|
|
e49b49af6c | ||
|
|
052e61b735 | ||
|
|
1ccac58403 | ||
|
|
04d74a7e07 | ||
|
|
0fdca8ddbd | ||
|
|
c5ca621efd | ||
|
|
8cefa566da | ||
|
|
7e4c8a5d0d | ||
|
|
edf33bedcb | ||
|
|
21e00168b1 | ||
|
|
da3730b7d6 | ||
|
|
770c766d50 | ||
|
|
5eb6926464 | ||
|
|
e478ae1c28 | ||
|
|
0c9284c8ae | ||
|
|
4eeadddfe6 | ||
|
|
9ca1847535 | ||
|
|
5d0bc95193 | ||
|
|
e7d6100fe4 | ||
|
|
01a95229f2 | ||
|
|
0939250f07 | ||
|
|
7147bce3c3 | ||
|
|
486fe34a2b | ||
|
|
922f43f520 | ||
|
|
e6fa01d514 | ||
|
|
8238fb49e0 | ||
|
|
430557676d | ||
|
|
8a5047c456 | ||
|
|
c787515894 | ||
|
|
d95d8032cc | ||
|
|
b9f72f4869 | ||
|
|
429bb6653a | ||
|
|
47caafa8f8 | ||
|
|
8530594f37 | ||
|
|
0b071c06f6 |
53
.github/workflows/helmcharts.yml
vendored
53
.github/workflows/helmcharts.yml
vendored
@@ -4,12 +4,13 @@ name: Build Helm Charts on Tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- "chart-*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -26,15 +27,49 @@ jobs:
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Build Subchart Deps
|
||||
run: |
|
||||
cd helm/librechat-rag-api
|
||||
helm dependency build
|
||||
cd helm/librechat
|
||||
helm dependency build
|
||||
cd ../librechat-rag-api
|
||||
helm dependency build
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
- name: Get Chart Version
|
||||
id: chart-version
|
||||
run: |
|
||||
CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
|
||||
echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run Helm OCI Charts Releaser
|
||||
# This is for the librechat chart
|
||||
- name: Release Helm OCI Charts for librechat
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# this is for the librechat-rag-api chart
|
||||
- name: Release Helm OCI Charts for librechat-rag-api
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat-rag-api
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat-rag-api
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,9 @@ pids
|
||||
*.seed
|
||||
.git
|
||||
|
||||
# CI/CD data
|
||||
test-image*
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -1222,7 +1222,9 @@ ${convo}
|
||||
}
|
||||
|
||||
if (this.isOmni === true && modelOptions.max_tokens != null) {
|
||||
modelOptions.max_completion_tokens = modelOptions.max_tokens;
|
||||
const paramName =
|
||||
modelOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
modelOptions[paramName] = modelOptions.max_tokens;
|
||||
delete modelOptions.max_tokens;
|
||||
}
|
||||
if (this.isOmni === true && modelOptions.temperature != null) {
|
||||
|
||||
@@ -364,17 +364,10 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
|
||||
if (shouldCreateVersion) {
|
||||
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions, actionsHash);
|
||||
if (duplicateVersion && !forceVersion) {
|
||||
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;
|
||||
// No changes detected, return the current agent without creating a new version
|
||||
const agentObj = currentAgent.toObject();
|
||||
agentObj.version = versions.length;
|
||||
return agentObj;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -942,45 +942,31 @@ describe('models/Agent', () => {
|
||||
expect(emptyParamsAgent.model_parameters).toEqual({});
|
||||
});
|
||||
|
||||
test('should detect duplicate versions and reject updates', async () => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
test('should not create new version for duplicate updates', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const testCases = generateVersionTestCases();
|
||||
|
||||
try {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const testCases = generateVersionTestCases();
|
||||
for (const testCase of testCases) {
|
||||
const testAgentId = `agent_${uuidv4()}`;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const testAgentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: testAgentId,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
...testCase.initial,
|
||||
});
|
||||
|
||||
await createAgent({
|
||||
id: testAgentId,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
...testCase.initial,
|
||||
});
|
||||
const updatedAgent = await updateAgent({ id: testAgentId }, testCase.update);
|
||||
expect(updatedAgent.versions).toHaveLength(2); // No new version created
|
||||
|
||||
await updateAgent({ id: testAgentId }, testCase.update);
|
||||
// Update with duplicate data should succeed but not create a new version
|
||||
const duplicateUpdate = await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||
|
||||
let error;
|
||||
try {
|
||||
await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
|
||||
|
||||
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;
|
||||
const agent = await getAgent({ id: testAgentId });
|
||||
expect(agent.versions).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1156,20 +1142,13 @@ describe('models/Agent', () => {
|
||||
expect(secondUpdate.versions).toHaveLength(3);
|
||||
|
||||
// Update without forceVersion and no changes should not create a version
|
||||
let error;
|
||||
try {
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
|
||||
{ updatingUserId: authorId.toString(), forceVersion: false },
|
||||
);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
const duplicateUpdate = await updateAgent(
|
||||
{ id: agentId },
|
||||
{ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
|
||||
{ updatingUserId: authorId.toString(), forceVersion: false },
|
||||
);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toContain('Duplicate version');
|
||||
expect(error.statusCode).toBe(409);
|
||||
expect(duplicateUpdate.versions).toHaveLength(3); // No new version created
|
||||
});
|
||||
|
||||
test('should handle isDuplicateVersion with arrays containing null/undefined values', async () => {
|
||||
@@ -1367,18 +1346,21 @@ describe('models/Agent', () => {
|
||||
expect(secondUpdate.versions).toHaveLength(3);
|
||||
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
|
||||
|
||||
// Try to update with same support_contact - should be detected as duplicate
|
||||
await expect(
|
||||
updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
support_contact: {
|
||||
name: 'Updated Support',
|
||||
email: 'updated@support.com',
|
||||
},
|
||||
// Try to update with same support_contact - should be detected as duplicate but return successfully
|
||||
const duplicateUpdate = await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
support_contact: {
|
||||
name: 'Updated Support',
|
||||
email: 'updated@support.com',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Duplicate version');
|
||||
},
|
||||
);
|
||||
|
||||
// Should not create a new version
|
||||
expect(duplicateUpdate.versions).toHaveLength(3);
|
||||
expect(duplicateUpdate.version).toBe(3);
|
||||
expect(duplicateUpdate.support_contact.email).toBe('updated@support.com');
|
||||
});
|
||||
|
||||
test('should handle support_contact from empty to populated', async () => {
|
||||
@@ -1562,18 +1544,22 @@ describe('models/Agent', () => {
|
||||
expect(updated.support_contact.name).toBe('New Name');
|
||||
expect(updated.support_contact.email).toBe('');
|
||||
|
||||
// Verify isDuplicateVersion works with partial changes
|
||||
await expect(
|
||||
updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
support_contact: {
|
||||
name: 'New Name',
|
||||
email: '',
|
||||
},
|
||||
// Verify isDuplicateVersion works with partial changes - should return successfully without creating new version
|
||||
const duplicateUpdate = await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
support_contact: {
|
||||
name: 'New Name',
|
||||
email: '',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Duplicate version');
|
||||
},
|
||||
);
|
||||
|
||||
// Should not create a new version since content is the same
|
||||
expect(duplicateUpdate.versions).toHaveLength(2);
|
||||
expect(duplicateUpdate.version).toBe(2);
|
||||
expect(duplicateUpdate.support_contact.name).toBe('New Name');
|
||||
expect(duplicateUpdate.support_contact.email).toBe('');
|
||||
});
|
||||
|
||||
// Edge Cases
|
||||
@@ -2793,11 +2779,18 @@ describe('models/Agent', () => {
|
||||
agent_ids: ['agent1', 'agent2'],
|
||||
});
|
||||
|
||||
await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] });
|
||||
const updatedAgent = await updateAgent(
|
||||
{ id: agentId },
|
||||
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
|
||||
);
|
||||
expect(updatedAgent.versions).toHaveLength(2);
|
||||
|
||||
await expect(
|
||||
updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] }),
|
||||
).rejects.toThrow('Duplicate version');
|
||||
// Update with same agent_ids should succeed but not create a new version
|
||||
const duplicateUpdate = await updateAgent(
|
||||
{ id: agentId },
|
||||
{ agent_ids: ['agent1', 'agent2', 'agent3'] },
|
||||
);
|
||||
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
|
||||
});
|
||||
|
||||
test('should handle agent_ids field alongside other fields', async () => {
|
||||
@@ -2936,9 +2929,10 @@ describe('models/Agent', () => {
|
||||
expect(updated.versions).toHaveLength(2);
|
||||
expect(updated.agent_ids).toEqual([]);
|
||||
|
||||
await expect(updateAgent({ id: agentId }, { agent_ids: [] })).rejects.toThrow(
|
||||
'Duplicate version',
|
||||
);
|
||||
// Update with same empty agent_ids should succeed but not create a new version
|
||||
const duplicateUpdate = await updateAgent({ id: agentId }, { agent_ids: [] });
|
||||
expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
|
||||
expect(duplicateUpdate.agent_ids).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle agent without agent_ids field', async () => {
|
||||
|
||||
@@ -247,10 +247,130 @@ const deletePromptGroup = async ({ _id, author, role }) => {
|
||||
return { message: 'Prompt group deleted successfully' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get prompt groups by accessible IDs with optional cursor-based pagination.
|
||||
* @param {Object} params - The parameters for getting accessible prompt groups.
|
||||
* @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
|
||||
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
|
||||
* @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
|
||||
* @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the prompt groups data and pagination info.
|
||||
*/
|
||||
async function getListPromptGroupsByAccess({
|
||||
accessibleIds = [],
|
||||
otherParams = {},
|
||||
limit = null,
|
||||
after = null,
|
||||
}) {
|
||||
const isPaginated = limit !== null && limit !== undefined;
|
||||
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
|
||||
|
||||
// Build base query combining ACL accessible prompt groups with other filters
|
||||
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
|
||||
|
||||
// Add cursor condition
|
||||
if (after) {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||
const { updatedAt, _id } = cursor;
|
||||
|
||||
const cursorCondition = {
|
||||
$or: [
|
||||
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||
{ updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
|
||||
],
|
||||
};
|
||||
|
||||
// Merge cursor condition with base query
|
||||
if (Object.keys(baseQuery).length > 0) {
|
||||
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||
// Remove the original conditions from baseQuery to avoid duplication
|
||||
Object.keys(baseQuery).forEach((key) => {
|
||||
if (key !== '$and') delete baseQuery[key];
|
||||
});
|
||||
} else {
|
||||
Object.assign(baseQuery, cursorCondition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Invalid cursor:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Build aggregation pipeline
|
||||
const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }];
|
||||
|
||||
// Only apply limit if pagination is requested
|
||||
if (isPaginated) {
|
||||
pipeline.push({ $limit: normalizedLimit + 1 });
|
||||
}
|
||||
|
||||
// Add lookup for production prompt
|
||||
pipeline.push(
|
||||
{
|
||||
$lookup: {
|
||||
from: 'prompts',
|
||||
localField: 'productionId',
|
||||
foreignField: '_id',
|
||||
as: 'productionPrompt',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
name: 1,
|
||||
numberOfGenerations: 1,
|
||||
oneliner: 1,
|
||||
category: 1,
|
||||
projectIds: 1,
|
||||
productionId: 1,
|
||||
author: 1,
|
||||
authorName: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
'productionPrompt.prompt': 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const promptGroups = await PromptGroup.aggregate(pipeline).exec();
|
||||
|
||||
const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
|
||||
const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
|
||||
(group) => {
|
||||
if (group.author) {
|
||||
group.author = group.author.toString();
|
||||
}
|
||||
return group;
|
||||
},
|
||||
);
|
||||
|
||||
// Generate next cursor only if paginated
|
||||
let nextCursor = null;
|
||||
if (isPaginated && hasMore && data.length > 0) {
|
||||
const lastGroup = promptGroups[normalizedLimit - 1];
|
||||
nextCursor = Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastGroup.updatedAt.toISOString(),
|
||||
_id: lastGroup._id.toString(),
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data,
|
||||
first_id: data.length > 0 ? data[0]._id.toString() : null,
|
||||
last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
|
||||
has_more: hasMore,
|
||||
after: nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPromptGroups,
|
||||
deletePromptGroup,
|
||||
getAllPromptGroups,
|
||||
getListPromptGroupsByAccess,
|
||||
/**
|
||||
* Create a prompt and its respective group
|
||||
* @param {TCreatePromptRecord} saveData
|
||||
|
||||
@@ -16,7 +16,7 @@ const { Role } = require('~/db/models');
|
||||
*
|
||||
* @param {string} roleName - The name of the role to find or create.
|
||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||
* @returns {Promise<Object>} A plain object representing the role document.
|
||||
* @returns {Promise<IRole>} Role document.
|
||||
*/
|
||||
const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
||||
const cache = getLogStores(CacheKeys.ROLES);
|
||||
@@ -72,8 +72,9 @@ const updateRoleByName = async function (roleName, updates) {
|
||||
* Updates access permissions for a specific role and multiple permission types.
|
||||
* @param {string} roleName - The role to update.
|
||||
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
|
||||
* @param {IRole} [roleData] - Optional role data to use instead of fetching from the database.
|
||||
*/
|
||||
async function updateAccessPermissions(roleName, permissionsUpdate) {
|
||||
async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
|
||||
// Filter and clean the permission updates based on our schema definition.
|
||||
const updates = {};
|
||||
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
|
||||
@@ -86,7 +87,7 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await getRoleByName(roleName);
|
||||
const role = roleData ?? (await getRoleByName(roleName));
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +114,6 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process the current updates
|
||||
for (const [permissionType, permissions] of Object.entries(updates)) {
|
||||
const currentTypePermissions = currentPermissions[permissionType] || {};
|
||||
updatedPermissions[permissionType] = { ...currentTypePermissions };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { matchModelName } = require('../utils');
|
||||
const { matchModelName } = require('../utils/tokens');
|
||||
const defaultRate = 6;
|
||||
|
||||
/**
|
||||
@@ -87,6 +87,9 @@ const tokenValues = Object.assign(
|
||||
'gpt-4.1': { prompt: 2, completion: 8 },
|
||||
'gpt-4.5': { prompt: 75, completion: 150 },
|
||||
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
||||
'gpt-5': { prompt: 1.25, completion: 10 },
|
||||
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
||||
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
||||
'gpt-4o': { prompt: 2.5, completion: 10 },
|
||||
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
|
||||
'gpt-4-1106': { prompt: 10, completion: 30 },
|
||||
@@ -147,6 +150,9 @@ const tokenValues = Object.assign(
|
||||
codestral: { prompt: 0.3, completion: 0.9 },
|
||||
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||
// GPT-OSS models
|
||||
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
|
||||
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
|
||||
},
|
||||
bedrockValues,
|
||||
);
|
||||
@@ -214,6 +220,12 @@ const getValueKey = (model, endpoint) => {
|
||||
return 'gpt-4.1';
|
||||
} else if (modelName.includes('gpt-4o-2024-05-13')) {
|
||||
return 'gpt-4o-2024-05-13';
|
||||
} else if (modelName.includes('gpt-5-nano')) {
|
||||
return 'gpt-5-nano';
|
||||
} else if (modelName.includes('gpt-5-mini')) {
|
||||
return 'gpt-5-mini';
|
||||
} else if (modelName.includes('gpt-5')) {
|
||||
return 'gpt-5';
|
||||
} else if (modelName.includes('gpt-4o-mini')) {
|
||||
return 'gpt-4o-mini';
|
||||
} else if (modelName.includes('gpt-4o')) {
|
||||
|
||||
@@ -25,8 +25,14 @@ describe('getValueKey', () => {
|
||||
expect(getValueKey('gpt-4-some-other-info')).toBe('8k');
|
||||
});
|
||||
|
||||
it('should return undefined for model names that do not match any known patterns', () => {
|
||||
expect(getValueKey('gpt-5-some-other-info')).toBeUndefined();
|
||||
it('should return "gpt-5" for model name containing "gpt-5"', () => {
|
||||
expect(getValueKey('gpt-5-some-other-info')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
|
||||
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
|
||||
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
|
||||
@@ -84,6 +90,29 @@ describe('getValueKey', () => {
|
||||
expect(getValueKey('gpt-4.1-nano-0125')).toBe('gpt-4.1-nano');
|
||||
});
|
||||
|
||||
it('should return "gpt-5" for model type of "gpt-5"', () => {
|
||||
expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
|
||||
expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
|
||||
expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
|
||||
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should return "gpt-5-mini" for model type of "gpt-5-mini"', () => {
|
||||
expect(getValueKey('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
|
||||
expect(getValueKey('openai/gpt-5-mini')).toBe('gpt-5-mini');
|
||||
expect(getValueKey('gpt-5-mini-0130')).toBe('gpt-5-mini');
|
||||
expect(getValueKey('gpt-5-mini-2025-01-30-0130')).toBe('gpt-5-mini');
|
||||
});
|
||||
|
||||
it('should return "gpt-5-nano" for model type of "gpt-5-nano"', () => {
|
||||
expect(getValueKey('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
|
||||
expect(getValueKey('openai/gpt-5-nano')).toBe('gpt-5-nano');
|
||||
expect(getValueKey('gpt-5-nano-0130')).toBe('gpt-5-nano');
|
||||
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
|
||||
});
|
||||
|
||||
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
|
||||
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
|
||||
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
|
||||
@@ -207,6 +236,48 @@ describe('getMultiplier', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-5', () => {
|
||||
const valueKey = getValueKey('gpt-5-2025-01-30');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5'].prompt);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'gpt-5-preview', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['gpt-5'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'openai/gpt-5', tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-5-mini', () => {
|
||||
const valueKey = getValueKey('gpt-5-mini-2025-01-30');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-mini'].prompt);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-mini'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'gpt-5-mini-preview', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['gpt-5-mini'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'openai/gpt-5-mini', tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-mini'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-5-nano', () => {
|
||||
const valueKey = getValueKey('gpt-5-nano-2025-01-30');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-nano'].prompt);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-nano'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'gpt-5-nano-preview', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['gpt-5-nano'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'openai/gpt-5-nano', tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-nano'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-4o', () => {
|
||||
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||
@@ -307,10 +378,22 @@ describe('getMultiplier', () => {
|
||||
});
|
||||
|
||||
it('should return defaultRate if derived valueKey does not match any known patterns', () => {
|
||||
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-5-some-other-info' })).toBe(
|
||||
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-10-some-other-info' })).toBe(
|
||||
defaultRate,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct multipliers for GPT-OSS models', () => {
|
||||
const models = ['gpt-oss-20b', 'gpt-oss-120b'];
|
||||
models.forEach((key) => {
|
||||
const expectedPrompt = tokenValues[key].prompt;
|
||||
const expectedCompletion = tokenValues[key].completion;
|
||||
expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
|
||||
expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
|
||||
expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
|
||||
expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS Bedrock Model Tests', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.69",
|
||||
"@librechat/agents": "^2.4.75",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
const { logger } = require('~/config');
|
||||
|
||||
//handle duplicates
|
||||
const handleDuplicateKeyError = (err, res) => {
|
||||
logger.error('Duplicate key error:', err.keyValue);
|
||||
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
//handle validation errors
|
||||
const handleValidationError = (err, res) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
let errors = Object.values(err.errors).map((el) => el.message);
|
||||
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
let code = 400;
|
||||
if (errors.length > 1) {
|
||||
errors = errors.join(' ');
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
} else {
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = (err, _req, res, _next) => {
|
||||
try {
|
||||
if (err.name === 'ValidationError') {
|
||||
return handleValidationError(err, res);
|
||||
}
|
||||
if (err.code && err.code == 11000) {
|
||||
return handleDuplicateKeyError(err, res);
|
||||
}
|
||||
// Special handling for errors like SyntaxError
|
||||
if (err.statusCode && err.body) {
|
||||
return res.status(err.statusCode).send(err.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (err) {
|
||||
logger.error('ErrorController => processing error', err);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req
|
||||
* @returns {Promise<TModelsConfig>} The models config.
|
||||
*/
|
||||
const getModelsConfig = async (req) => {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
|
||||
@@ -1,54 +1,16 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, AuthType, Constants } = require('librechat-data-provider');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const {
|
||||
getToolkitKey,
|
||||
checkPluginAuth,
|
||||
filterUniquePlugins,
|
||||
convertMCPToolsToPlugins,
|
||||
} = require('@librechat/api');
|
||||
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||
const { availableTools, toolkits } = require('~/app/clients/tools');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { availableTools } = require('~/app/clients/tools');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Filters out duplicate plugins from the list of plugins.
|
||||
*
|
||||
* @param {TPlugin[]} plugins The list of plugins to filter.
|
||||
* @returns {TPlugin[]} The list of plugins with duplicates removed.
|
||||
*/
|
||||
const filterUniquePlugins = (plugins) => {
|
||||
const seen = new Set();
|
||||
return plugins.filter((plugin) => {
|
||||
const duplicate = seen.has(plugin.pluginKey);
|
||||
seen.add(plugin.pluginKey);
|
||||
return !duplicate;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values.
|
||||
* Supports alternate authentication fields, allowing validation against multiple possible environment variables.
|
||||
*
|
||||
* @param {TPlugin} plugin The plugin object containing the authentication configuration.
|
||||
* @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise.
|
||||
*/
|
||||
const checkPluginAuth = (plugin) => {
|
||||
if (!plugin.authConfig || plugin.authConfig.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return plugin.authConfig.every((authFieldObj) => {
|
||||
const authFieldOptions = authFieldObj.authField.split('||');
|
||||
let isFieldAuthenticated = false;
|
||||
|
||||
for (const fieldOption of authFieldOptions) {
|
||||
const envValue = process.env[fieldOption];
|
||||
if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) {
|
||||
isFieldAuthenticated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return isFieldAuthenticated;
|
||||
});
|
||||
};
|
||||
|
||||
const getAvailablePluginsController = async (req, res) => {
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
@@ -143,9 +105,9 @@ const getAvailableTools = async (req, res) => {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||
const cachedUserTools = await getCachedTools({ userId });
|
||||
const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
|
||||
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
|
||||
|
||||
if (cachedToolsArray && userPlugins) {
|
||||
if (cachedToolsArray != null && userPlugins != null) {
|
||||
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
|
||||
res.status(200).json(dedupedTools);
|
||||
return;
|
||||
@@ -185,7 +147,9 @@ const getAvailableTools = async (req, res) => {
|
||||
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
|
||||
const isToolkit =
|
||||
plugin.toolkit === true &&
|
||||
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
|
||||
Object.keys(toolDefinitions).some(
|
||||
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
|
||||
);
|
||||
|
||||
if (!isToolDefined && !isToolkit) {
|
||||
continue;
|
||||
@@ -235,58 +199,6 @@ const getAvailableTools = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts MCP function format tools to plugin format
|
||||
* @param {Object} functionTools - Object with function format tools
|
||||
* @param {Object} customConfig - Custom configuration for MCP servers
|
||||
* @returns {Array} Array of plugin objects
|
||||
*/
|
||||
function convertMCPToolsToPlugins(functionTools, customConfig) {
|
||||
const plugins = [];
|
||||
|
||||
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const functionData = toolData.function;
|
||||
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
|
||||
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||
|
||||
const plugin = {
|
||||
name: parts[0], // Use the tool name without server suffix
|
||||
pluginKey: toolKey,
|
||||
description: functionData.description || '',
|
||||
authenticated: true,
|
||||
icon: serverConfig?.iconPath,
|
||||
};
|
||||
|
||||
// Build authConfig for MCP tools
|
||||
if (!serverConfig?.customUserVars) {
|
||||
plugin.authConfig = [];
|
||||
plugins.push(plugin);
|
||||
continue;
|
||||
}
|
||||
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
if (customVarKeys.length === 0) {
|
||||
plugin.authConfig = [];
|
||||
} else {
|
||||
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAvailableTools,
|
||||
getAvailablePluginsController,
|
||||
|
||||
@@ -28,19 +28,211 @@ jest.mock('~/config', () => ({
|
||||
|
||||
jest.mock('~/app/clients/tools', () => ({
|
||||
availableTools: [],
|
||||
toolkits: [],
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
getToolkitKey: jest.fn(),
|
||||
checkPluginAuth: jest.fn(),
|
||||
filterUniquePlugins: jest.fn(),
|
||||
convertMCPToolsToPlugins: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the actual module with the function we want to test
|
||||
const { getAvailableTools } = require('./PluginController');
|
||||
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
|
||||
const {
|
||||
filterUniquePlugins,
|
||||
checkPluginAuth,
|
||||
convertMCPToolsToPlugins,
|
||||
getToolkitKey,
|
||||
} = require('@librechat/api');
|
||||
|
||||
describe('PluginController', () => {
|
||||
describe('plugin.icon behavior', () => {
|
||||
let mockReq, mockRes, mockCache;
|
||||
let mockReq, mockRes, mockCache;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = { user: { id: 'test-user-id' } };
|
||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||
getLogStores.mockReturnValue(mockCache);
|
||||
});
|
||||
|
||||
describe('getAvailablePluginsController', () => {
|
||||
beforeEach(() => {
|
||||
mockReq.app = { locals: { filteredTools: [], includedTools: [] } };
|
||||
});
|
||||
|
||||
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(filterUniquePlugins).toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
|
||||
expect(mockRes.json).toHaveBeenCalledWith([
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
||||
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
||||
checkPluginAuth.mockReturnValueOnce(true);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData[0].authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should return cached plugins when available', async () => {
|
||||
const cachedPlugins = [
|
||||
{ name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(cachedPlugins);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(filterUniquePlugins).not.toHaveBeenCalled();
|
||||
expect(checkPluginAuth).not.toHaveBeenCalled();
|
||||
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
|
||||
});
|
||||
|
||||
it('should filter plugins based on includedTools', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
mockReq.app.locals.includedTools = ['key1'];
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(1);
|
||||
expect(responseData[0].pluginKey).toBe('key1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableTools', () => {
|
||||
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
|
||||
const mockUserTools = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
},
|
||||
};
|
||||
const mockConvertedPlugins = [
|
||||
{
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Tool 1',
|
||||
},
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: mockUserTools,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
||||
const mockUserPlugins = [
|
||||
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
|
||||
];
|
||||
const mockManifestPlugins = [
|
||||
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(mockManifestPlugins);
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
|
||||
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should be called to deduplicate the combined array
|
||||
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
|
||||
...mockUserPlugins,
|
||||
...mockManifestPlugins,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify authentication status', async () => {
|
||||
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock getCachedTools second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
||||
});
|
||||
|
||||
it('should use getToolkitKey for toolkit validation', async () => {
|
||||
const mockToolkit = {
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
description: 'Toolkit 1',
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
getToolkitKey.mockReturnValue('toolkit1');
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock getCachedTools second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
||||
toolkit1_function: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(getToolkitKey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin.icon behavior', () => {
|
||||
const callGetAvailableToolsWithMCPServer = async (mcpServers) => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCustomConfig.mockResolvedValue({ mcpServers });
|
||||
@@ -50,7 +242,22 @@ describe('PluginController', () => {
|
||||
function: { name: 'test-tool', description: 'A test tool' },
|
||||
},
|
||||
};
|
||||
|
||||
const mockConvertedPlugin = {
|
||||
name: 'test-tool',
|
||||
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
|
||||
description: 'A test tool',
|
||||
icon: mcpServers['test-server']?.iconPath,
|
||||
authenticated: true,
|
||||
authConfig: [],
|
||||
};
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(functionTools);
|
||||
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
getToolkitKey.mockReturnValue(undefined);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
@@ -60,14 +267,6 @@ describe('PluginController', () => {
|
||||
return responseData.find((tool) => tool.name === 'test-tool');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = { user: { id: 'test-user-id' } };
|
||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||
getLogStores.mockReturnValue(mockCache);
|
||||
});
|
||||
|
||||
it('should set plugin.icon when iconPath is defined', async () => {
|
||||
const mcpServers = {
|
||||
'test-server': {
|
||||
@@ -86,4 +285,236 @@ describe('PluginController', () => {
|
||||
expect(testTool.icon).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper function integration', () => {
|
||||
it('should properly handle MCP tools with custom user variables', async () => {
|
||||
const customConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// We need to test the actual flow where MCP manager tools are included
|
||||
const mcpManagerTools = [
|
||||
{
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
|
||||
description: 'Tool 1',
|
||||
authenticated: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the MCP manager to return tools
|
||||
const mockMCPManager = {
|
||||
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCustomConfig.mockResolvedValue(customConfig);
|
||||
|
||||
// First call returns user tools (empty in this case)
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
|
||||
// Mock convertMCPToolsToPlugins to return empty array for user tools
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
|
||||
// Mock filterUniquePlugins to pass through
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
|
||||
// Mock checkPluginAuth
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
// Second call returns tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Find the MCP tool in the response
|
||||
const mcpTool = responseData.find(
|
||||
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
|
||||
);
|
||||
|
||||
// The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
|
||||
expect(mcpTool).toBeDefined();
|
||||
expect(mcpTool.authConfig).toEqual([
|
||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
||||
]);
|
||||
expect(mcpTool.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error cases gracefully', async () => {
|
||||
mockCache.get.mockRejectedValue(new Error('Cache error'));
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with undefined/null values', () => {
|
||||
it('should handle undefined cache gracefully', async () => {
|
||||
getLogStores.mockReturnValue(undefined);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it('should handle null cachedTools and cachedUserTools', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: null,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle when getCachedTools returns undefined', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(undefined);
|
||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
// Mock getCachedTools to return undefined for both calls
|
||||
getCachedTools.mockReset();
|
||||
getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: undefined,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
|
||||
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
|
||||
const userTools = {
|
||||
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
|
||||
};
|
||||
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
|
||||
|
||||
mockCache.get.mockResolvedValue(cachedTools);
|
||||
getCachedTools.mockResolvedValue(userTools);
|
||||
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
|
||||
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
|
||||
});
|
||||
|
||||
it('should handle empty toolDefinitions object', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// With empty tool definitions, no tools should be in the final output
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle MCP tools without customUserVars', async () => {
|
||||
const customConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
// No customUserVars defined
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserTools = {
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: {
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
},
|
||||
};
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCustomConfig.mockResolvedValue(customConfig);
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
|
||||
const mockPlugin = {
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
|
||||
description: 'Tool 1',
|
||||
authenticated: true,
|
||||
authConfig: [],
|
||||
};
|
||||
|
||||
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData[0].authenticated).toBe(true);
|
||||
// The actual implementation doesn't set authConfig on tools without customUserVars
|
||||
expect(responseData[0].authConfig).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
|
||||
mockReq.app = { locals: {} };
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue([]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle toolkit with undefined toolDefinitions keys', async () => {
|
||||
const mockToolkit = {
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
description: 'Toolkit 1',
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
getToolkitKey.mockReturnValue(undefined);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock getCachedTools second call to return null
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should handle null toolDefinitions gracefully
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Disable 2FA by clearing the stored secret and backup codes.
|
||||
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
|
||||
*/
|
||||
const disable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
let isVerified = false;
|
||||
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
} else {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'Either token or backup code is required to disable 2FA' });
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return res.status(401).json({ message: 'Invalid token or backup code' });
|
||||
}
|
||||
}
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
|
||||
return res.status(200).json();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { webSearchKeys, extractWebSearchEnvVars } = require('@librechat/api');
|
||||
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
|
||||
const {
|
||||
getFiles,
|
||||
updateUser,
|
||||
@@ -89,8 +89,8 @@ const updateUserPluginsController = async (req, res) => {
|
||||
|
||||
if (userPluginsService instanceof Error) {
|
||||
logger.error('[userPluginsService]', userPluginsService);
|
||||
const { status, message } = userPluginsService;
|
||||
res.status(status).send({ message });
|
||||
const { status, message } = normalizeHttpError(userPluginsService);
|
||||
return res.status(status).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ const updateUserPluginsController = async (req, res) => {
|
||||
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
|
||||
if (authService instanceof Error) {
|
||||
logger.error('[authService]', authService);
|
||||
({ status, message } = authService);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
}
|
||||
} else if (action === 'uninstall') {
|
||||
@@ -151,7 +151,7 @@ const updateUserPluginsController = async (req, res) => {
|
||||
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
|
||||
authService,
|
||||
);
|
||||
({ status, message } = authService);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
} else {
|
||||
// This handles:
|
||||
@@ -163,7 +163,7 @@ const updateUserPluginsController = async (req, res) => {
|
||||
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
|
||||
if (authService instanceof Error) {
|
||||
logger.error('[authService] Error deleting specific auth key:', authService);
|
||||
({ status, message } = authService);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,8 @@ const updateUserPluginsController = async (req, res) => {
|
||||
return res.status(status).send();
|
||||
}
|
||||
|
||||
res.status(status).send({ message });
|
||||
const normalized = normalizeHttpError({ status, message });
|
||||
return res.status(normalized.status).send({ message: normalized.message });
|
||||
} catch (err) {
|
||||
logger.error('[updateUserPluginsController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
|
||||
@@ -402,6 +402,34 @@ class AgentClient extends BaseClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves with the memory promise result or undefined after a timeout
|
||||
* @param {Promise<(TAttachment | null)[] | undefined>} memoryPromise - The memory promise to await
|
||||
* @param {number} timeoutMs - Timeout in milliseconds (default: 3000)
|
||||
* @returns {Promise<(TAttachment | null)[] | undefined>}
|
||||
*/
|
||||
async awaitMemoryWithTimeout(memoryPromise, timeoutMs = 3000) {
|
||||
if (!memoryPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs),
|
||||
);
|
||||
|
||||
const attachments = await Promise.race([memoryPromise, timeoutPromise]);
|
||||
return attachments;
|
||||
} catch (error) {
|
||||
if (error.message === 'Memory processing timeout') {
|
||||
logger.warn('[AgentClient] Memory processing timed out after 3 seconds');
|
||||
} else {
|
||||
logger.error('[AgentClient] Error processing memory:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
@@ -1002,11 +1030,9 @@ class AgentClient extends BaseClient {
|
||||
});
|
||||
|
||||
try {
|
||||
if (memoryPromise) {
|
||||
const attachments = await memoryPromise;
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
await this.recordCollectedUsage({ context: 'message' });
|
||||
@@ -1017,11 +1043,9 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (memoryPromise) {
|
||||
const attachments = await memoryPromise;
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
@@ -1123,11 +1147,16 @@ class AgentClient extends BaseClient {
|
||||
clientOptions.configuration = options.configOptions;
|
||||
}
|
||||
|
||||
// Ensure maxTokens is set for non-o1 models
|
||||
if (!/\b(o\d)\b/i.test(clientOptions.model) && !clientOptions.maxTokens) {
|
||||
clientOptions.maxTokens = 75;
|
||||
} else if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
const shouldRemoveMaxTokens = /\b(o\d|gpt-[5-9])\b/i.test(clientOptions.model);
|
||||
if (shouldRemoveMaxTokens && clientOptions.maxTokens != null) {
|
||||
delete clientOptions.maxTokens;
|
||||
} else if (!shouldRemoveMaxTokens && !clientOptions.maxTokens) {
|
||||
clientOptions.maxTokens = 75;
|
||||
}
|
||||
if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_completion_tokens != null) {
|
||||
delete clientOptions.modelKwargs.max_completion_tokens;
|
||||
} else if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_output_tokens != null) {
|
||||
delete clientOptions.modelKwargs.max_output_tokens;
|
||||
}
|
||||
|
||||
clientOptions = Object.assign(
|
||||
|
||||
@@ -728,6 +728,239 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptions method - GPT-5+ model handling', () => {
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: {
|
||||
model: 'gpt-5',
|
||||
},
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
app: {
|
||||
locals: {},
|
||||
},
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
};
|
||||
|
||||
mockRes = {};
|
||||
|
||||
mockOptions = {
|
||||
req: mockReq,
|
||||
res: mockRes,
|
||||
agent: mockAgent,
|
||||
};
|
||||
|
||||
client = new AgentClient(mockOptions);
|
||||
});
|
||||
|
||||
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
|
||||
const clientOptions = {
|
||||
model: 'gpt-5',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic that handles GPT-5+ models
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs).toBeDefined();
|
||||
expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
|
||||
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
|
||||
});
|
||||
|
||||
it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
|
||||
const clientOptions = {
|
||||
model: 'gpt-5',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.7,
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs).toBeDefined();
|
||||
expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
|
||||
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
|
||||
});
|
||||
|
||||
it('should handle GPT-5+ models with existing modelKwargs', () => {
|
||||
const clientOptions = {
|
||||
model: 'gpt-6',
|
||||
maxTokens: 1500,
|
||||
temperature: 0.8,
|
||||
modelKwargs: {
|
||||
customParam: 'value',
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs).toEqual({
|
||||
customParam: 'value',
|
||||
max_completion_tokens: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify maxTokens for non-GPT-5+ models', () => {
|
||||
const clientOptions = {
|
||||
model: 'gpt-4',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
// Should not be modified since it's GPT-4
|
||||
expect(clientOptions.maxTokens).toBe(2048);
|
||||
expect(clientOptions.modelKwargs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle various GPT-5+ model formats', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
{ model: 'gpt-7-preview', shouldTransform: true },
|
||||
{ model: 'gpt-8', shouldTransform: true },
|
||||
{ model: 'gpt-9-mini', shouldTransform: true },
|
||||
{ model: 'gpt-4', shouldTransform: false },
|
||||
{ model: 'gpt-4o', shouldTransform: false },
|
||||
{ model: 'gpt-3.5-turbo', shouldTransform: false },
|
||||
{ model: 'claude-3', shouldTransform: false },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, shouldTransform }) => {
|
||||
const clientOptions = {
|
||||
model,
|
||||
maxTokens: 1000,
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
if (shouldTransform) {
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
|
||||
} else {
|
||||
expect(clientOptions.maxTokens).toBe(1000);
|
||||
expect(clientOptions.modelKwargs).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not swap max token param for older models when using useResponsesApi', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
{ model: 'gpt-7-preview', shouldTransform: true },
|
||||
{ model: 'gpt-8', shouldTransform: true },
|
||||
{ model: 'gpt-9-mini', shouldTransform: true },
|
||||
{ model: 'gpt-4', shouldTransform: false },
|
||||
{ model: 'gpt-4o', shouldTransform: false },
|
||||
{ model: 'gpt-3.5-turbo', shouldTransform: false },
|
||||
{ model: 'claude-3', shouldTransform: false },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, shouldTransform }) => {
|
||||
const clientOptions = {
|
||||
model,
|
||||
maxTokens: 1000,
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
if (shouldTransform) {
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
|
||||
} else {
|
||||
expect(clientOptions.maxTokens).toBe(1000);
|
||||
expect(clientOptions.modelKwargs).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not transform if maxTokens is null or undefined', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5', maxTokens: null },
|
||||
{ model: 'gpt-5', maxTokens: undefined },
|
||||
{ model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, maxTokens }, index) => {
|
||||
const clientOptions = {
|
||||
model,
|
||||
maxTokens,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
|
||||
if (index < 2) {
|
||||
// null or undefined cases
|
||||
expect(clientOptions.maxTokens).toBe(maxTokens);
|
||||
expect(clientOptions.modelKwargs).toBeUndefined();
|
||||
} else {
|
||||
// 0 case - should transform
|
||||
expect(clientOptions.maxTokens).toBeUndefined();
|
||||
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMemory method', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
|
||||
@@ -233,6 +233,26 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Edge case: sendMessage completed but abort happened during sendCompletion
|
||||
// We need to ensure a final event is sent
|
||||
else if (!res.headersSent && !res.finished) {
|
||||
logger.debug(
|
||||
'[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
|
||||
);
|
||||
|
||||
const finalResponse = { ...response };
|
||||
finalResponse.error = true;
|
||||
|
||||
sendEvent(res, {
|
||||
final: true,
|
||||
conversation,
|
||||
title: conversation.title,
|
||||
requestMessage: userMessage,
|
||||
responseMessage: finalResponse,
|
||||
error: { message: 'Request was aborted during completion' },
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
|
||||
// Save user message if needed
|
||||
if (!client.skipSaveUserMessage) {
|
||||
|
||||
@@ -217,6 +217,9 @@ const updateAgentHandler = async (req, res) => {
|
||||
})
|
||||
: existingAgent;
|
||||
|
||||
// Add version count to the response
|
||||
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
|
||||
|
||||
if (updatedAgent.author) {
|
||||
updatedAgent.author = updatedAgent.author.toString();
|
||||
}
|
||||
|
||||
@@ -549,6 +549,28 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
|
||||
});
|
||||
|
||||
test('should include version field in update response', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Updated with Version Check',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Verify version field is included and is a number
|
||||
expect(updatedAgent).toHaveProperty('version');
|
||||
expect(typeof updatedAgent.version).toBe('number');
|
||||
expect(updatedAgent.version).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(updatedAgent.version).toBe(agentInDb.versions.length);
|
||||
});
|
||||
|
||||
test('should handle validation errors properly', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
|
||||
@@ -8,14 +8,12 @@ const express = require('express');
|
||||
const passport = require('passport');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
@@ -122,8 +120,7 @@ const startServer = async () => {
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
// Add the error controller one more time after all routes
|
||||
app.use(errorController);
|
||||
app.use(ErrorController);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.set({
|
||||
|
||||
@@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
|
||||
if (response.status === 200) {
|
||||
return; // App is healthy
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore connection errors during polling
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const validateModel = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
|
||||
|
||||
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
|
||||
const errorMessage = {
|
||||
|
||||
@@ -13,6 +13,8 @@ const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const memoryPayloadLimit = express.json({ limit: '100kb' });
|
||||
|
||||
const checkMemoryRead = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.READ],
|
||||
@@ -60,6 +62,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
let usagePercentage = null;
|
||||
if (tokenLimit && tokenLimit > 0) {
|
||||
@@ -70,6 +73,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
memories: sortedMemories,
|
||||
totalTokens,
|
||||
tokenLimit: tokenLimit || null,
|
||||
charLimit,
|
||||
usagePercentage,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -83,7 +87,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
* Body: { key: string, value: string }
|
||||
* Returns 201 and { created: true, memory: <createdDoc> } when successful.
|
||||
*/
|
||||
router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
|
||||
const { key, value } = req.body;
|
||||
|
||||
if (typeof key !== 'string' || key.trim() === '') {
|
||||
@@ -94,13 +98,25 @@ router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (key.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
|
||||
// Check token limit
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
|
||||
if (tokenLimit) {
|
||||
@@ -175,7 +191,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
||||
* Body: { key?: string, value: string }
|
||||
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
||||
*/
|
||||
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => {
|
||||
const { key: urlKey } = req.params;
|
||||
const { key: bodyKey, value } = req.body || {};
|
||||
|
||||
@@ -183,9 +199,23 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
// Use the key from the body if provided, otherwise use the key from the URL
|
||||
const newKey = bodyKey || urlKey;
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (newKey.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
@@ -196,7 +226,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Memory not found.' });
|
||||
}
|
||||
|
||||
// If the key is changing, we need to handle it specially
|
||||
if (newKey !== urlKey) {
|
||||
const keyExists = memories.find((m) => m.key === newKey);
|
||||
if (keyExists) {
|
||||
@@ -219,7 +248,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||
}
|
||||
} else {
|
||||
// Key is not changing, just update the value
|
||||
const result = await setMemory({
|
||||
userId: req.user.id,
|
||||
key: newKey,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { randomState } = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
@@ -11,8 +14,6 @@ const {
|
||||
} = require('~/server/middleware');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -48,13 +49,13 @@ const oauthHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
router.get('/error', (req, res) => {
|
||||
// A single error message is pushed by passport when authentication fails.
|
||||
/** A single error message is pushed by passport when authentication fails. */
|
||||
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
|
||||
logger.error('Error in OAuth authentication:', {
|
||||
message: req.session?.messages?.pop() || 'Unknown error',
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||
res.redirect(`${domains.client}/login?redirect=false`);
|
||||
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const {
|
||||
generateCheckAccess,
|
||||
markPublicPromptGroups,
|
||||
buildPromptGroupFilter,
|
||||
formatPromptGroupsResponse,
|
||||
createEmptyPromptGroupsResponse,
|
||||
filterAccessibleIdsBySharedLogic,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Permissions,
|
||||
SystemRoles,
|
||||
@@ -11,12 +18,11 @@ const {
|
||||
PermissionTypes,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
getListPromptGroupsByAccess,
|
||||
makePromptProduction,
|
||||
getAllPromptGroups,
|
||||
updatePromptGroup,
|
||||
deletePromptGroup,
|
||||
createPromptGroup,
|
||||
getPromptGroups,
|
||||
getPromptGroup,
|
||||
deletePrompt,
|
||||
getPrompts,
|
||||
@@ -95,23 +101,48 @@ router.get(
|
||||
router.get('/all', async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { name, category, ...otherFilters } = req.query;
|
||||
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
|
||||
name,
|
||||
category,
|
||||
...otherFilters,
|
||||
});
|
||||
|
||||
// Get promptGroup IDs the user has VIEW access to via ACL
|
||||
const accessibleIds = await findAccessibleResources({
|
||||
let accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
const groups = await getAllPromptGroups(req, {});
|
||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
// Filter the results to only include accessible groups
|
||||
const accessibleGroups = groups.filter((group) =>
|
||||
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
||||
);
|
||||
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
|
||||
accessibleIds,
|
||||
searchShared,
|
||||
searchSharedOnly,
|
||||
publicPromptGroupIds: publiclyAccessibleIds,
|
||||
});
|
||||
|
||||
res.status(200).send(accessibleGroups);
|
||||
const result = await getListPromptGroupsByAccess({
|
||||
accessibleIds: filteredAccessibleIds,
|
||||
otherParams: filter,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return res.status(200).send([]);
|
||||
}
|
||||
|
||||
const { data: promptGroups = [] } = result;
|
||||
if (!promptGroups.length) {
|
||||
return res.status(200).send([]);
|
||||
}
|
||||
|
||||
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
|
||||
res.status(200).send(groupsWithPublicFlag);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||
@@ -125,40 +156,66 @@ router.get('/all', async (req, res) => {
|
||||
router.get('/groups', async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const filter = { ...req.query };
|
||||
delete filter.author; // Remove author filter as we'll use ACL
|
||||
const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query;
|
||||
|
||||
// Get promptGroup IDs the user has VIEW access to via ACL
|
||||
const accessibleIds = await findAccessibleResources({
|
||||
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
|
||||
name,
|
||||
category,
|
||||
...otherFilters,
|
||||
});
|
||||
|
||||
let actualLimit = limit;
|
||||
let actualCursor = cursor;
|
||||
|
||||
if (pageSize && !limit) {
|
||||
actualLimit = parseInt(pageSize, 10);
|
||||
}
|
||||
|
||||
let accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
// Get publicly accessible promptGroups
|
||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
const groups = await getPromptGroups(req, filter);
|
||||
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
|
||||
accessibleIds,
|
||||
searchShared,
|
||||
searchSharedOnly,
|
||||
publicPromptGroupIds: publiclyAccessibleIds,
|
||||
});
|
||||
|
||||
if (groups.promptGroups && groups.promptGroups.length > 0) {
|
||||
groups.promptGroups = groups.promptGroups.filter((group) =>
|
||||
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
||||
);
|
||||
const result = await getListPromptGroupsByAccess({
|
||||
accessibleIds: filteredAccessibleIds,
|
||||
otherParams: filter,
|
||||
limit: actualLimit,
|
||||
after: actualCursor,
|
||||
});
|
||||
|
||||
// Mark public groups
|
||||
groups.promptGroups = groups.promptGroups.map((group) => {
|
||||
if (publiclyAccessibleIds.some((id) => id.equals(group._id))) {
|
||||
group.isPublic = true;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
if (!result) {
|
||||
const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit });
|
||||
return res.status(200).send(emptyResponse);
|
||||
}
|
||||
|
||||
res.status(200).send(groups);
|
||||
const { data: promptGroups = [], has_more = false, after = null } = result;
|
||||
|
||||
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
|
||||
|
||||
const response = formatPromptGroupsResponse({
|
||||
promptGroups: groupsWithPublicFlag,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
actualLimit,
|
||||
hasMore: has_more,
|
||||
after,
|
||||
});
|
||||
|
||||
res.status(200).send(response);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||
@@ -188,7 +245,6 @@ const createNewPromptGroup = async (req, res) => {
|
||||
|
||||
const result = await createPromptGroup(saveData);
|
||||
|
||||
// Grant owner permissions to the creator on the new promptGroup
|
||||
if (result.prompt && result.prompt._id && result.prompt.groupId) {
|
||||
try {
|
||||
await grantPermission({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
agentPermissionsSchema,
|
||||
promptPermissionsSchema,
|
||||
memoryPermissionsSchema,
|
||||
agentPermissionsSchema,
|
||||
PermissionTypes,
|
||||
roleDefaults,
|
||||
SystemRoles,
|
||||
marketplacePermissionsSchema,
|
||||
peoplePickerPermissionsSchema,
|
||||
} = require('librechat-data-provider');
|
||||
const { checkAdmin, requireJwtAuth } = require('~/server/middleware');
|
||||
const { updateRoleByName, getRoleByName } = require('~/models/Role');
|
||||
@@ -13,6 +15,81 @@ const { updateRoleByName, getRoleByName } = require('~/models/Role');
|
||||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/**
|
||||
* Permission configuration mapping
|
||||
* Maps route paths to their corresponding schemas and permission types
|
||||
*/
|
||||
const permissionConfigs = {
|
||||
prompts: {
|
||||
schema: promptPermissionsSchema,
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
errorMessage: 'Invalid prompt permissions.',
|
||||
},
|
||||
agents: {
|
||||
schema: agentPermissionsSchema,
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
errorMessage: 'Invalid agent permissions.',
|
||||
},
|
||||
memories: {
|
||||
schema: memoryPermissionsSchema,
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
errorMessage: 'Invalid memory permissions.',
|
||||
},
|
||||
'people-picker': {
|
||||
schema: peoplePickerPermissionsSchema,
|
||||
permissionType: PermissionTypes.PEOPLE_PICKER,
|
||||
errorMessage: 'Invalid people picker permissions.',
|
||||
},
|
||||
marketplace: {
|
||||
schema: marketplacePermissionsSchema,
|
||||
permissionType: PermissionTypes.MARKETPLACE,
|
||||
errorMessage: 'Invalid marketplace permissions.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic handler for updating permissions
|
||||
* @param {string} permissionKey - The key from permissionConfigs
|
||||
* @returns {Function} Express route handler
|
||||
*/
|
||||
const createPermissionUpdateHandler = (permissionKey) => {
|
||||
const config = permissionConfigs[permissionKey];
|
||||
|
||||
return async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
const updates = req.body;
|
||||
|
||||
try {
|
||||
const parsedUpdates = config.schema.partial().parse(updates);
|
||||
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
const currentPermissions =
|
||||
role.permissions?.[config.permissionType] || role[config.permissionType] || {};
|
||||
|
||||
const mergedUpdates = {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
[config.permissionType]: {
|
||||
...currentPermissions,
|
||||
...parsedUpdates,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
|
||||
res.status(200).send(updatedRole);
|
||||
} catch (error) {
|
||||
return res.status(400).send({ message: config.errorMessage, error: error.errors });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/roles/:roleName
|
||||
* Get a specific role by name
|
||||
@@ -45,117 +122,30 @@ router.get('/:roleName', async (req, res) => {
|
||||
* PUT /api/roles/:roleName/prompts
|
||||
* Update prompt permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/prompts', checkAdmin, async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
/** @type {TRole['permissions']['PROMPTS']} */
|
||||
const updates = req.body;
|
||||
|
||||
try {
|
||||
const parsedUpdates = promptPermissionsSchema.partial().parse(updates);
|
||||
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
const currentPermissions =
|
||||
role.permissions?.[PermissionTypes.PROMPTS] || role[PermissionTypes.PROMPTS] || {};
|
||||
|
||||
const mergedUpdates = {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
...currentPermissions,
|
||||
...parsedUpdates,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
|
||||
res.status(200).send(updatedRole);
|
||||
} catch (error) {
|
||||
return res.status(400).send({ message: 'Invalid prompt permissions.', error: error.errors });
|
||||
}
|
||||
});
|
||||
router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/agents
|
||||
* Update agent permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/agents', checkAdmin, async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
/** @type {TRole['permissions']['AGENTS']} */
|
||||
const updates = req.body;
|
||||
|
||||
try {
|
||||
const parsedUpdates = agentPermissionsSchema.partial().parse(updates);
|
||||
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
const currentPermissions =
|
||||
role.permissions?.[PermissionTypes.AGENTS] || role[PermissionTypes.AGENTS] || {};
|
||||
|
||||
const mergedUpdates = {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
[PermissionTypes.AGENTS]: {
|
||||
...currentPermissions,
|
||||
...parsedUpdates,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
|
||||
res.status(200).send(updatedRole);
|
||||
} catch (error) {
|
||||
return res.status(400).send({ message: 'Invalid agent permissions.', error: error.errors });
|
||||
}
|
||||
});
|
||||
router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/memories
|
||||
* Update memory permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/memories', checkAdmin, async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
/** @type {TRole['permissions']['MEMORIES']} */
|
||||
const updates = req.body;
|
||||
router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories'));
|
||||
|
||||
try {
|
||||
const parsedUpdates = memoryPermissionsSchema.partial().parse(updates);
|
||||
/**
|
||||
* PUT /api/roles/:roleName/people-picker
|
||||
* Update people picker permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
|
||||
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
const currentPermissions =
|
||||
role.permissions?.[PermissionTypes.MEMORIES] || role[PermissionTypes.MEMORIES] || {};
|
||||
|
||||
const mergedUpdates = {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
...currentPermissions,
|
||||
...parsedUpdates,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
|
||||
res.status(200).send(updatedRole);
|
||||
} catch (error) {
|
||||
return res.status(400).send({ message: 'Invalid memory permissions.', error: error.errors });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* PUT /api/roles/:roleName/marketplace
|
||||
* Update marketplace permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,7 +5,7 @@ jest.mock('~/models', () => ({
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
getRoleByName: jest.fn(),
|
||||
getRoleByName: jest.fn().mockResolvedValue(null),
|
||||
updateRoleByName: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -94,109 +94,71 @@ describe('AppService interface configuration', () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
user: {
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: false,
|
||||
},
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
user: {
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: false,
|
||||
},
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
|
||||
expect(app.locals.interfaceConfig.peoplePicker.admin).toMatchObject({
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
});
|
||||
expect(app.locals.interfaceConfig.peoplePicker.user).toMatchObject({
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: false,
|
||||
});
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed peoplePicker permissions for roles', async () => {
|
||||
it('should handle mixed peoplePicker permissions', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: false,
|
||||
},
|
||||
user: {
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: false,
|
||||
},
|
||||
user: {
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(false);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(false);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
|
||||
});
|
||||
|
||||
it('should set default peoplePicker roles permissions when not provided', async () => {
|
||||
it('should set default peoplePicker permissions when not provided', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
user: {
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: false,
|
||||
},
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
|
||||
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(false);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
loadOCRConfig,
|
||||
EModelEndpoint,
|
||||
loadMemoryConfig,
|
||||
getConfigDefaults,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
|
||||
@@ -33,6 +33,7 @@ jest.mock('~/models', () => ({
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
getRoleByName: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
jest.mock('./Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
@@ -970,20 +971,13 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||
expect(app.locals.ocr.mistralModel).toEqual('mistral-medium');
|
||||
});
|
||||
|
||||
it('should correctly configure peoplePicker with roles permission when specified', async () => {
|
||||
it('should correctly configure peoplePicker permissions when specified', async () => {
|
||||
const mockConfig = {
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
user: {
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -993,21 +987,16 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||
const app = { locals: {} };
|
||||
await AppService(app);
|
||||
|
||||
// Check that interface config includes the roles permission
|
||||
// Check that interface config includes the permissions
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
|
||||
expect(app.locals.interfaceConfig.peoplePicker.admin).toMatchObject({
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toMatchObject({
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
});
|
||||
expect(app.locals.interfaceConfig.peoplePicker.user).toMatchObject({
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default peoplePicker roles permissions when not specified', async () => {
|
||||
it('should use default peoplePicker permissions when not specified', async () => {
|
||||
const mockConfig = {
|
||||
interface: {
|
||||
// No peoplePicker configuration
|
||||
@@ -1019,9 +1008,10 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||
const app = { locals: {} };
|
||||
await AppService(app);
|
||||
|
||||
// Check that default roles permissions are applied
|
||||
// Check that default permissions are applied
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
|
||||
expect(app.locals.interfaceConfig.peoplePicker.admin.roles).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.user.roles).toBe(false);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,14 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
|
||||
|
||||
// Find boundaries between ARTIFACT_START and ARTIFACT_END
|
||||
const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1;
|
||||
const contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
|
||||
let contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
|
||||
|
||||
// Special case: if contentEnd is 0, it means the only ::: found is at the start of :::artifact
|
||||
// This indicates an incomplete artifact (no closing :::)
|
||||
// We need to check that it's exactly at position 0 (the beginning of artifactContent)
|
||||
if (contentEnd === 0 && artifactContent.indexOf(ARTIFACT_START) === 0) {
|
||||
contentEnd = artifactContent.length;
|
||||
}
|
||||
|
||||
if (contentStart === -1 || contentEnd === -1) {
|
||||
return null;
|
||||
@@ -72,12 +79,20 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => {
|
||||
|
||||
// Determine where to look for the original content
|
||||
let searchStart, searchEnd;
|
||||
if (codeBlockStart !== -1 && codeBlockEnd !== -1) {
|
||||
// If code blocks exist, search between them
|
||||
if (codeBlockStart !== -1) {
|
||||
// Code block starts
|
||||
searchStart = codeBlockStart + 4; // after ```\n
|
||||
searchEnd = codeBlockEnd;
|
||||
|
||||
if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) {
|
||||
// Code block has proper ending
|
||||
searchEnd = codeBlockEnd;
|
||||
} else {
|
||||
// No closing backticks found or they're before the opening (shouldn't happen)
|
||||
// This might be an incomplete artifact - search to contentEnd
|
||||
searchEnd = contentEnd;
|
||||
}
|
||||
} else {
|
||||
// Otherwise search in the whole artifact content
|
||||
// No code blocks at all
|
||||
searchStart = contentStart;
|
||||
searchEnd = contentEnd;
|
||||
}
|
||||
|
||||
@@ -89,9 +89,9 @@ describe('replaceArtifactContent', () => {
|
||||
};
|
||||
|
||||
test('should replace content within artifact boundaries', () => {
|
||||
const original = 'console.log(\'hello\')';
|
||||
const original = "console.log('hello')";
|
||||
const artifact = createTestArtifact(original);
|
||||
const updated = 'console.log(\'updated\')';
|
||||
const updated = "console.log('updated')";
|
||||
|
||||
const result = replaceArtifactContent(artifact.text, artifact, original, updated);
|
||||
expect(result).toContain(updated);
|
||||
@@ -317,4 +317,182 @@ console.log(greeting);`;
|
||||
expect(result).not.toContain('\n\n```');
|
||||
expect(result).not.toContain('```\n\n');
|
||||
});
|
||||
|
||||
describe('incomplete artifacts', () => {
|
||||
test('should handle incomplete artifacts (missing closing ::: and ```)', () => {
|
||||
const original = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pomodoro</title>
|
||||
<meta name="description" content="A single-file Pomodoro timer with logs, charts, sounds, and dark mode." />
|
||||
<style>
|
||||
:root{`;
|
||||
|
||||
const prefix = `Awesome idea! I'll deliver a complete single-file HTML app called "Pomodoro" with:
|
||||
- Custom session/break durations
|
||||
|
||||
You can save this as pomodoro.html and open it directly in your browser.
|
||||
|
||||
`;
|
||||
|
||||
// This simulates the real incomplete artifact case - no closing ``` or :::
|
||||
const incompleteArtifact = `${ARTIFACT_START}{identifier="pomodoro-single-file-app" type="text/html" title="Pomodoro — Single File App"}
|
||||
\`\`\`
|
||||
${original}`;
|
||||
|
||||
const fullText = prefix + incompleteArtifact;
|
||||
const message = { text: fullText };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0].end).toBe(fullText.length);
|
||||
|
||||
const updated = original.replace('Pomodoro</title>', 'Pomodoro</title>UPDATED');
|
||||
const result = replaceArtifactContent(fullText, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('UPDATED');
|
||||
expect(result).toContain(prefix);
|
||||
// Should not have added closing markers
|
||||
expect(result).not.toMatch(/:::\s*$/);
|
||||
});
|
||||
|
||||
test('should handle incomplete artifacts with only opening code block', () => {
|
||||
const original = 'function hello() { console.log("world"); }';
|
||||
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n\`\`\`\n${original}`;
|
||||
|
||||
const message = { text: incompleteArtifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
|
||||
const updated = 'function hello() { console.log("UPDATED"); }';
|
||||
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('UPDATED');
|
||||
});
|
||||
|
||||
test('should handle incomplete artifacts without code blocks', () => {
|
||||
const original = 'Some plain text content';
|
||||
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`;
|
||||
|
||||
const message = { text: incompleteArtifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
|
||||
const updated = 'Some UPDATED text content';
|
||||
const result = replaceArtifactContent(incompleteArtifact, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('UPDATED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regression tests for edge cases', () => {
|
||||
test('should still handle complete artifacts correctly', () => {
|
||||
// Ensure we didn't break normal artifact handling
|
||||
const original = 'console.log("test");';
|
||||
const artifact = createArtifactText({ content: original });
|
||||
|
||||
const message = { text: artifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
|
||||
const updated = 'console.log("updated");';
|
||||
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain(updated);
|
||||
expect(result).toContain(ARTIFACT_END);
|
||||
expect(result).toMatch(/```\nconsole\.log\("updated"\);\n```/);
|
||||
});
|
||||
|
||||
test('should handle multiple complete artifacts', () => {
|
||||
// Ensure multiple artifacts still work
|
||||
const content1 = 'First artifact';
|
||||
const content2 = 'Second artifact';
|
||||
const text = `${createArtifactText({ content: content1 })}\n\n${createArtifactText({ content: content2 })}`;
|
||||
|
||||
const message = { text };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(2);
|
||||
|
||||
// Update first artifact
|
||||
const result1 = replaceArtifactContent(text, artifacts[0], content1, 'First UPDATED');
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result1).toContain('First UPDATED');
|
||||
expect(result1).toContain(content2);
|
||||
|
||||
// Update second artifact
|
||||
const result2 = replaceArtifactContent(text, artifacts[1], content2, 'Second UPDATED');
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result2).toContain(content1);
|
||||
expect(result2).toContain('Second UPDATED');
|
||||
});
|
||||
|
||||
test('should not mistake ::: at position 0 for artifact end in complete artifacts', () => {
|
||||
// This tests the specific fix - ensuring contentEnd=0 doesn't break complete artifacts
|
||||
const original = 'test content';
|
||||
// Create an artifact that will have ::: at position 0 when substring'd
|
||||
const artifact = `${ARTIFACT_START}\n\`\`\`\n${original}\n\`\`\`\n${ARTIFACT_END}`;
|
||||
|
||||
const message = { text: artifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
|
||||
const updated = 'updated content';
|
||||
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain(updated);
|
||||
expect(result).toContain(ARTIFACT_END);
|
||||
});
|
||||
|
||||
test('should handle empty artifacts', () => {
|
||||
// Edge case: empty artifact
|
||||
const artifact = `${ARTIFACT_START}\n${ARTIFACT_END}`;
|
||||
|
||||
const message = { text: artifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
|
||||
// Trying to replace non-existent content should return null
|
||||
const result = replaceArtifactContent(artifact, artifacts[0], 'something', 'updated');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should preserve whitespace and formatting in complete artifacts', () => {
|
||||
const original = ` function test() {
|
||||
return {
|
||||
value: 42
|
||||
};
|
||||
}`;
|
||||
const artifact = createArtifactText({ content: original });
|
||||
|
||||
const message = { text: artifact };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
const updated = ` function test() {
|
||||
return {
|
||||
value: 100
|
||||
};
|
||||
}`;
|
||||
const result = replaceArtifactContent(artifact, artifacts[0], original, updated);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('value: 100');
|
||||
// Should preserve exact formatting
|
||||
expect(result).toMatch(
|
||||
/```\n {2}function test\(\) \{\n {4}return \{\n {6}value: 100\n {4}\};\n {2}\}\n```/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { useAzurePlugins } = require('~/server/services/Config/EndpointService').config;
|
||||
const {
|
||||
getAnthropicModels,
|
||||
getBedrockModels,
|
||||
getOpenAIModels,
|
||||
getGoogleModels,
|
||||
getBedrockModels,
|
||||
getAnthropicModels,
|
||||
} = require('~/server/services/ModelService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default models for the application.
|
||||
@@ -16,58 +15,42 @@ const { logger } = require('~/config');
|
||||
*/
|
||||
async function loadDefaultModels(req) {
|
||||
try {
|
||||
const [
|
||||
openAI,
|
||||
anthropic,
|
||||
azureOpenAI,
|
||||
gptPlugins,
|
||||
assistants,
|
||||
azureAssistants,
|
||||
google,
|
||||
bedrock,
|
||||
] = await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch(
|
||||
(error) => {
|
||||
logger.error('Error fetching Plugin models:', error);
|
||||
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
|
||||
await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
},
|
||||
),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.agents]: openAI,
|
||||
[EModelEndpoint.google]: google,
|
||||
[EModelEndpoint.anthropic]: anthropic,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.assistants]: assistants,
|
||||
[EModelEndpoint.azureAssistants]: azureAssistants,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { validateAgentModel } = require('@librechat/api');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
Constants,
|
||||
@@ -11,10 +12,12 @@ const {
|
||||
getDefaultHandlers,
|
||||
} = require('~/server/controllers/agents/callbacks');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { getCustomEndpointConfig } = require('~/server/services/Config');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
function createToolLoader() {
|
||||
/**
|
||||
@@ -72,6 +75,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
throw new Error('Agent not found');
|
||||
}
|
||||
|
||||
const modelsConfig = await getModelsConfig(req);
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
agent: primaryAgent,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const agentConfigs = new Map();
|
||||
/** @type {Set<string>} */
|
||||
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
|
||||
@@ -101,6 +117,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { getToolkitKey } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { zodToJsonSchema } = require('zod-to-json-schema');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
@@ -11,7 +12,6 @@ const {
|
||||
ErrorTypes,
|
||||
ContentTypes,
|
||||
imageGenTools,
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
actionDelimiter,
|
||||
ImageVisionTool,
|
||||
@@ -40,30 +40,6 @@ const { recordUsage } = require('~/server/services/Threads');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
const { redactMessage } = require('~/config/parsers');
|
||||
|
||||
/**
|
||||
* @param {string} toolName
|
||||
* @returns {string | undefined} toolKey
|
||||
*/
|
||||
function getToolkitKey(toolName) {
|
||||
/** @type {string|undefined} */
|
||||
let toolkitKey;
|
||||
for (const toolkit of toolkits) {
|
||||
if (toolName.startsWith(EToolResources.image_edit)) {
|
||||
const splitMatches = toolkit.pluginKey.split('_');
|
||||
const suffix = splitMatches[splitMatches.length - 1];
|
||||
if (toolName.endsWith(suffix)) {
|
||||
toolkitKey = toolkit.pluginKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (toolName.startsWith(toolkit.pluginKey)) {
|
||||
toolkitKey = toolkit.pluginKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return toolkitKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and formats tools from the specified tool directory.
|
||||
*
|
||||
@@ -145,7 +121,7 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] })
|
||||
for (const toolInstance of basicToolInstances) {
|
||||
const formattedTool = formatToOpenAIAssistantTool(toolInstance);
|
||||
let toolName = formattedTool[Tools.function].name;
|
||||
toolName = getToolkitKey(toolName) ?? toolName;
|
||||
toolName = getToolkitKey({ toolkits, toolName }) ?? toolName;
|
||||
if (filter.has(toolName) && included.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,90 @@ const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
isMemoryEnabled,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { logger } = require('~/config');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isMemoryEnabled } = require('@librechat/api');
|
||||
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
|
||||
|
||||
/**
|
||||
* Updates role permissions intelligently - only updates permission types that:
|
||||
* 1. Don't exist in the database (first time setup)
|
||||
* 2. Are explicitly configured in the config file
|
||||
* @param {object} params - The role name to update
|
||||
* @param {string} params.roleName - The role name to update
|
||||
* @param {object} params.allPermissions - All permissions to potentially update
|
||||
* @param {object} params.interfaceConfig - The interface config from librechat.yaml
|
||||
*/
|
||||
async function updateRolePermissions({ roleName, allPermissions, interfaceConfig }) {
|
||||
const existingRole = await getRoleByName(roleName);
|
||||
const existingPermissions = existingRole?.permissions || {};
|
||||
const permissionsToUpdate = {};
|
||||
|
||||
for (const [permType, perms] of Object.entries(allPermissions)) {
|
||||
const permTypeExists = existingPermissions[permType];
|
||||
|
||||
const isExplicitlyConfigured = interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
|
||||
|
||||
// Only update if: doesn't exist OR explicitly configured
|
||||
if (!permTypeExists || isExplicitlyConfigured) {
|
||||
permissionsToUpdate[permType] = perms;
|
||||
if (!permTypeExists) {
|
||||
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
|
||||
} else if (isExplicitlyConfigured) {
|
||||
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(permissionsToUpdate).length > 0) {
|
||||
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a permission type has explicit configuration
|
||||
*/
|
||||
function hasExplicitConfig(interfaceConfig, permissionType) {
|
||||
switch (permissionType) {
|
||||
case PermissionTypes.PROMPTS:
|
||||
return interfaceConfig.prompts !== undefined;
|
||||
case PermissionTypes.BOOKMARKS:
|
||||
return interfaceConfig.bookmarks !== undefined;
|
||||
case PermissionTypes.MEMORIES:
|
||||
return interfaceConfig.memories !== undefined;
|
||||
case PermissionTypes.MULTI_CONVO:
|
||||
return interfaceConfig.multiConvo !== undefined;
|
||||
case PermissionTypes.AGENTS:
|
||||
return interfaceConfig.agents !== undefined;
|
||||
case PermissionTypes.TEMPORARY_CHAT:
|
||||
return interfaceConfig.temporaryChat !== undefined;
|
||||
case PermissionTypes.RUN_CODE:
|
||||
return interfaceConfig.runCode !== undefined;
|
||||
case PermissionTypes.WEB_SEARCH:
|
||||
return interfaceConfig.webSearch !== undefined;
|
||||
case PermissionTypes.PEOPLE_PICKER:
|
||||
return interfaceConfig.peoplePicker !== undefined;
|
||||
case PermissionTypes.MARKETPLACE:
|
||||
return interfaceConfig.marketplace !== undefined;
|
||||
case PermissionTypes.FILE_SEARCH:
|
||||
return interfaceConfig.fileSearch !== undefined;
|
||||
case PermissionTypes.FILE_CITATIONS:
|
||||
return interfaceConfig.fileCitations !== undefined;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the default interface object.
|
||||
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
|
||||
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
|
||||
* @param {SystemRoles} [roleName] - The role to load the default interface for, defaults to `'USER'`.
|
||||
* @returns {Promise<TCustomConfig['interface']>} The default interface object.
|
||||
*/
|
||||
async function loadDefaultInterface(config, configDefaults, roleName = SystemRoles.USER) {
|
||||
async function loadDefaultInterface(config, configDefaults) {
|
||||
const { interface: interfaceConfig } = config ?? {};
|
||||
const { interface: defaults } = configDefaults;
|
||||
const hasModelSpecs = config?.modelSpecs?.list?.length > 0;
|
||||
@@ -54,73 +124,44 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
||||
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
|
||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||
peoplePicker: {
|
||||
admin: {
|
||||
users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users,
|
||||
groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups,
|
||||
roles: interfaceConfig?.peoplePicker?.admin?.roles ?? defaults.peoplePicker?.admin.roles,
|
||||
},
|
||||
user: {
|
||||
users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users,
|
||||
groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups,
|
||||
roles: interfaceConfig?.peoplePicker?.user?.roles ?? defaults.peoplePicker?.user.roles,
|
||||
},
|
||||
users: interfaceConfig?.peoplePicker?.users ?? defaults.peoplePicker?.users,
|
||||
groups: interfaceConfig?.peoplePicker?.groups ?? defaults.peoplePicker?.groups,
|
||||
roles: interfaceConfig?.peoplePicker?.roles ?? defaults.peoplePicker?.roles,
|
||||
},
|
||||
marketplace: {
|
||||
admin: {
|
||||
use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use,
|
||||
},
|
||||
user: {
|
||||
use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use,
|
||||
},
|
||||
use: interfaceConfig?.marketplace?.use ?? defaults.marketplace?.use,
|
||||
},
|
||||
});
|
||||
|
||||
await updateAccessPermissions(roleName, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: loadedInterface.memories,
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users,
|
||||
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups,
|
||||
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker.user?.roles,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: loadedInterface.marketplace.user?.use,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
});
|
||||
await updateAccessPermissions(SystemRoles.ADMIN, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: loadedInterface.memories,
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users,
|
||||
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups,
|
||||
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker.admin?.roles,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: loadedInterface.marketplace.admin?.use,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
});
|
||||
for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) {
|
||||
await updateRolePermissions({
|
||||
roleName,
|
||||
allPermissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: loadedInterface.memories,
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker?.users,
|
||||
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker?.groups,
|
||||
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker?.roles,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: loadedInterface.marketplace?.use,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
},
|
||||
interfaceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const logSettings = () => {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
const { SystemRoles, Permissions, PermissionTypes } = require('librechat-data-provider');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
|
||||
const { loadDefaultInterface } = require('./interface');
|
||||
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
getRoleByName: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('loadDefaultInterface', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock getRoleByName to return null (no existing permissions)
|
||||
getRoleByName.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when permission types are true', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
@@ -20,13 +27,21 @@ describe('loadDefaultInterface', () => {
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
@@ -38,14 +53,31 @@ describe('loadDefaultInterface', () => {
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when permission types are false', async () => {
|
||||
@@ -61,13 +93,21 @@ describe('loadDefaultInterface', () => {
|
||||
webSearch: false,
|
||||
fileSearch: false,
|
||||
fileCitations: false,
|
||||
peoplePicker: {
|
||||
users: false,
|
||||
groups: false,
|
||||
roles: false,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
|
||||
@@ -76,14 +116,31 @@ describe('loadDefaultInterface', () => {
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
|
||||
});
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with undefined when permission types are not specified in config', async () => {
|
||||
@@ -92,7 +149,7 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
@@ -106,52 +163,29 @@ describe('loadDefaultInterface', () => {
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with undefined when permission types are explicitly undefined', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: undefined,
|
||||
bookmarks: undefined,
|
||||
memories: undefined,
|
||||
multiConvo: undefined,
|
||||
agents: undefined,
|
||||
temporaryChat: undefined,
|
||||
runCode: undefined,
|
||||
webSearch: undefined,
|
||||
fileSearch: undefined,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with mixed values for permission types', async () => {
|
||||
@@ -173,7 +207,7 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
@@ -184,15 +218,103 @@ describe('loadDefaultInterface', () => {
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with true when config is undefined', async () => {
|
||||
it('should use default values when config is undefined', async () => {
|
||||
const config = undefined;
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: true,
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should only update permissions that do not exist when no config provided', async () => {
|
||||
// Mock that some permissions already exist
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
},
|
||||
});
|
||||
|
||||
const config = undefined;
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
@@ -211,372 +333,110 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
// Should be called with all permissions EXCEPT prompts and agents (which already exist)
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when multiConvo is true', async () => {
|
||||
const config = { interface: { multiConvo: true } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
it('should override existing permissions when explicitly configured', async () => {
|
||||
// Mock that some permissions already exist
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when multiConvo is false', async () => {
|
||||
const config = { interface: { multiConvo: false } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with undefined when multiConvo is not specified in config', async () => {
|
||||
const config = {};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with all interface options including multiConvo', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: false,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
fileSearch: true,
|
||||
prompts: true, // Explicitly set, should override existing false
|
||||
// agents not specified, so existing false should be preserved
|
||||
// bookmarks not specified, so existing false should be preserved
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default values for multiConvo when config is undefined', async () => {
|
||||
const config = undefined;
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
prompts: false,
|
||||
agents: true,
|
||||
bookmarks: true,
|
||||
memories: false,
|
||||
multiConvo: false,
|
||||
agents: undefined,
|
||||
temporaryChat: undefined,
|
||||
runCode: undefined,
|
||||
webSearch: undefined,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
// Should update prompts (explicitly configured) and all other permissions that don't exist
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, // Explicitly configured
|
||||
// All other permissions that don't exist in the database
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when WEB_SEARCH is undefined', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: false,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when FILE_SEARCH is true', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when FILE_SEARCH is false', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
fileSearch: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with all interface options including fileSearch', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: false,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when fileCitations is true', async () => {
|
||||
const config = { interface: { fileCitations: true } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when fileCitations is false', async () => {
|
||||
const config = { interface: { fileCitations: false } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const LdapStrategy = require('passport-ldapauth');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
||||
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const {
|
||||
LDAP_URL,
|
||||
@@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
||||
|
||||
let user = await findUser({ ldapId });
|
||||
if (user && user.provider !== 'ldap') {
|
||||
logger.info(
|
||||
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
||||
const fullName =
|
||||
|
||||
@@ -3,9 +3,9 @@ const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -320,6 +320,14 @@ async function setupOpenId() {
|
||||
} for openidId: ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
if (user != null && user.provider !== 'openid') {
|
||||
logger.info(
|
||||
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('node-fetch');
|
||||
@@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
|
||||
issuer: 'https://fake-issuer.com',
|
||||
// Add any other properties needed by the implementation
|
||||
}),
|
||||
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
|
||||
fetchUserInfo: jest.fn().mockImplementation(() => {
|
||||
// Only return additional properties, but don't override any claims
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
@@ -261,17 +262,20 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
// Arrange – simulate that a user already exists with openid provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: '',
|
||||
username: '',
|
||||
name: '',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
if (
|
||||
query.openidId === tokenset.claims().sub ||
|
||||
(query.email === tokenset.claims().email && query.provider === 'openid')
|
||||
) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
@@ -294,12 +298,38 @@ describe('setupOpenId', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Arrange – simulate that a user exists with same email but different provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'google',
|
||||
email: tokenset.claims().email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.email === tokenset.claims().email && !query.provider) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset);
|
||||
@@ -310,9 +340,6 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = false;
|
||||
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
const hasManualFlag =
|
||||
typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
|
||||
|
||||
if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
updatedAvatar = avatarUrl;
|
||||
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
} else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
const userId = oldUser._id;
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId,
|
||||
|
||||
164
api/strategies/process.test.js
Normal file
164
api/strategies/process.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { handleExistingUser } = require('./process');
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||||
resizeAvatar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
updateUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getBalanceConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser } = require('~/models');
|
||||
|
||||
describe('handleExistingUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.CDN_PROVIDER = FileSources.local;
|
||||
});
|
||||
|
||||
it('should handle null avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle undefined avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
// avatar is undefined
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should not update avatar if it has manual=true flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update avatar for local storage when avatar has no manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/old-avatar.png',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should process avatar for non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
|
||||
getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
|
||||
resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||||
userId: 'user123',
|
||||
input: avatarUrl,
|
||||
});
|
||||
expect(mockProcessAvatar).toHaveBeenCalledWith({
|
||||
buffer: Buffer.from('resized-image'),
|
||||
userId: 'user123',
|
||||
manual: 'false',
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
|
||||
});
|
||||
|
||||
it('should not update if avatar already has manual flag in non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://cdn.example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle avatar with query parameters but without manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?size=large&format=webp',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle empty string avatar', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: '',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle avatar with manual=false parameter', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=false',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle oldUser being null gracefully', async () => {
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
// This should throw an error when trying to access oldUser._id
|
||||
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -203,6 +204,15 @@ async function setupSaml() {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && user.provider !== 'saml') {
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullName = getFullName(profile);
|
||||
|
||||
const username = convertToUsername(
|
||||
|
||||
@@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Set up findUser to return an existing user
|
||||
// Set up findUser to return an existing user with saml provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'local',
|
||||
provider: 'saml',
|
||||
email: baseProfile.email,
|
||||
samlId: '',
|
||||
username: 'oldusername',
|
||||
@@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
|
||||
expect(user.email).toBe(baseProfile.email);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Set up findUser to return a user with different provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'google',
|
||||
email: baseProfile.email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockResolvedValue(existingUser);
|
||||
|
||||
const profile = { ...baseProfile };
|
||||
const result = await validate(profile);
|
||||
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
const profile = { ...baseProfile };
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
const socialLogin =
|
||||
@@ -11,12 +12,20 @@ const socialLogin =
|
||||
profile,
|
||||
});
|
||||
|
||||
const oldUser = await findUser({ email: email.trim() });
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.provider = existingUser.provider;
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
|
||||
@@ -888,6 +888,12 @@
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports IRole
|
||||
* @typedef {import('@librechat/data-schemas').IRole} IRole
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ObjectId
|
||||
* @typedef {import('mongoose').Types.ObjectId} ObjectId
|
||||
|
||||
@@ -19,6 +19,9 @@ const openAIModels = {
|
||||
'gpt-4.1': 1047576,
|
||||
'gpt-4.1-mini': 1047576,
|
||||
'gpt-4.1-nano': 1047576,
|
||||
'gpt-5': 400000,
|
||||
'gpt-5-mini': 400000,
|
||||
'gpt-5-nano': 400000,
|
||||
'gpt-4o': 127500, // -500 from max
|
||||
'gpt-4o-mini': 127500, // -500 from max
|
||||
'gpt-4o-2024-05-13': 127500, // -500 from max
|
||||
@@ -234,6 +237,9 @@ const aggregateModels = {
|
||||
...xAIModels,
|
||||
// misc.
|
||||
kimi: 131000,
|
||||
// GPT-OSS
|
||||
'gpt-oss-20b': 131000,
|
||||
'gpt-oss-120b': 131000,
|
||||
};
|
||||
|
||||
const maxTokensMap = {
|
||||
@@ -250,6 +256,11 @@ const modelMaxOutputs = {
|
||||
o1: 32268, // -500 from max: 32,768
|
||||
'o1-mini': 65136, // -500 from max: 65,536
|
||||
'o1-preview': 32268, // -500 from max: 32,768
|
||||
'gpt-5': 128000,
|
||||
'gpt-5-mini': 128000,
|
||||
'gpt-5-nano': 128000,
|
||||
'gpt-oss-20b': 131000,
|
||||
'gpt-oss-120b': 131000,
|
||||
system_default: 1024,
|
||||
};
|
||||
|
||||
@@ -468,10 +479,11 @@ const tiktokenModels = new Set([
|
||||
]);
|
||||
|
||||
module.exports = {
|
||||
tiktokenModels,
|
||||
maxTokensMap,
|
||||
inputSchema,
|
||||
modelSchema,
|
||||
maxTokensMap,
|
||||
tiktokenModels,
|
||||
maxOutputTokensMap,
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getModelMaxTokens, processModelData, matchModelName, maxTokensMap } = require('./tokens');
|
||||
const {
|
||||
maxOutputTokensMap,
|
||||
getModelMaxTokens,
|
||||
processModelData,
|
||||
matchModelName,
|
||||
maxTokensMap,
|
||||
} = require('./tokens');
|
||||
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for exact match', () => {
|
||||
@@ -150,6 +156,35 @@ describe('getModelMaxTokens', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for gpt-5 matches', () => {
|
||||
expect(getModelMaxTokens('gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
|
||||
expect(getModelMaxTokens('gpt-5-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
|
||||
expect(getModelMaxTokens('openai/gpt-5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5']);
|
||||
expect(getModelMaxTokens('gpt-5-2025-01-30')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for gpt-5-mini matches', () => {
|
||||
expect(getModelMaxTokens('gpt-5-mini')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini']);
|
||||
expect(getModelMaxTokens('gpt-5-mini-preview')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
|
||||
);
|
||||
expect(getModelMaxTokens('openai/gpt-5-mini')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for gpt-5-nano matches', () => {
|
||||
expect(getModelMaxTokens('gpt-5-nano')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano']);
|
||||
expect(getModelMaxTokens('gpt-5-nano-preview')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
|
||||
);
|
||||
expect(getModelMaxTokens('openai/gpt-5-nano')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-nano'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Anthropic models', () => {
|
||||
const models = [
|
||||
'claude-2.1',
|
||||
@@ -349,6 +384,39 @@ describe('getModelMaxTokens', () => {
|
||||
expect(getModelMaxTokens('o3')).toBe(o3Tokens);
|
||||
expect(getModelMaxTokens('openai/o3')).toBe(o3Tokens);
|
||||
});
|
||||
|
||||
test('should return correct tokens for GPT-OSS models', () => {
|
||||
const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b'];
|
||||
['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => {
|
||||
expect(getModelMaxTokens(name)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-5 models', () => {
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
maxOutputTokensMap[EModelEndpoint.openAI][model],
|
||||
);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
|
||||
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-OSS models', () => {
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-oss-20b', 'gpt-oss-120b'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
maxOutputTokensMap[EModelEndpoint.openAI][model],
|
||||
);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
|
||||
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchModelName', () => {
|
||||
@@ -420,6 +488,25 @@ describe('matchModelName', () => {
|
||||
expect(matchModelName('gpt-4.1-nano-2024-08-06')).toBe('gpt-4.1-nano');
|
||||
});
|
||||
|
||||
it('should return the closest matching key for gpt-5 matches', () => {
|
||||
expect(matchModelName('openai/gpt-5')).toBe('gpt-5');
|
||||
expect(matchModelName('gpt-5-preview')).toBe('gpt-5');
|
||||
expect(matchModelName('gpt-5-2025-01-30')).toBe('gpt-5');
|
||||
expect(matchModelName('gpt-5-2025-01-30-0130')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should return the closest matching key for gpt-5-mini matches', () => {
|
||||
expect(matchModelName('openai/gpt-5-mini')).toBe('gpt-5-mini');
|
||||
expect(matchModelName('gpt-5-mini-preview')).toBe('gpt-5-mini');
|
||||
expect(matchModelName('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
|
||||
});
|
||||
|
||||
it('should return the closest matching key for gpt-5-nano matches', () => {
|
||||
expect(matchModelName('openai/gpt-5-nano')).toBe('gpt-5-nano');
|
||||
expect(matchModelName('gpt-5-nano-preview')).toBe('gpt-5-nano');
|
||||
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
|
||||
});
|
||||
|
||||
// Tests for Google models
|
||||
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
|
||||
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
46
client/src/Providers/ArtifactsContext.tsx
Normal file
46
client/src/Providers/ArtifactsContext.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from './ChatContext';
|
||||
import { getLatestText } from '~/utils';
|
||||
|
||||
interface ArtifactsContextValue {
|
||||
isSubmitting: boolean;
|
||||
latestMessageId: string | null;
|
||||
latestMessageText: string;
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
|
||||
|
||||
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
||||
|
||||
const latestMessageText = useMemo(() => {
|
||||
return getLatestText({
|
||||
messageId: latestMessage?.messageId ?? null,
|
||||
text: latestMessage?.text ?? null,
|
||||
content: latestMessage?.content ?? null,
|
||||
} as TMessage);
|
||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
() => ({
|
||||
isSubmitting,
|
||||
latestMessageText,
|
||||
latestMessageId: latestMessage?.messageId ?? null,
|
||||
conversationId: conversation?.conversationId ?? null,
|
||||
}),
|
||||
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
|
||||
);
|
||||
|
||||
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useArtifactsContext() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (!context) {
|
||||
throw new Error('useArtifactsContext must be used within ArtifactsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
76
client/src/Providers/PromptGroupsContext.tsx
Normal file
76
client/src/Providers/PromptGroupsContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { useGetAllPromptGroups } from '~/data-provider';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
import { mapPromptGroups } from '~/utils';
|
||||
|
||||
type AllPromptGroupsData =
|
||||
| {
|
||||
promptsMap: Record<string, TPromptGroup>;
|
||||
promptGroups: PromptOption[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type PromptGroupsContextType =
|
||||
| (ReturnType<typeof usePromptGroupsNav> & {
|
||||
allPromptGroups: {
|
||||
data: AllPromptGroupsData;
|
||||
isLoading: boolean;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
|
||||
const PromptGroupsContext = createContext<PromptGroupsContextType>(null);
|
||||
|
||||
export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const promptGroupsNav = usePromptGroupsNav();
|
||||
const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, {
|
||||
select: (data) => {
|
||||
const mappedArray: PromptOption[] = data.map((group) => ({
|
||||
id: group._id ?? '',
|
||||
type: 'prompt',
|
||||
value: group.command ?? group.name,
|
||||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||
group.name
|
||||
}: ${
|
||||
(group.oneliner?.length ?? 0) > 0
|
||||
? group.oneliner
|
||||
: (group.productionPrompt?.prompt ?? '')
|
||||
}`,
|
||||
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
||||
}));
|
||||
|
||||
const promptsMap = mapPromptGroups(data);
|
||||
|
||||
return {
|
||||
promptsMap,
|
||||
promptGroups: mappedArray,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...promptGroupsNav,
|
||||
allPromptGroups: {
|
||||
data: allGroupsData,
|
||||
isLoading: isLoadingAll,
|
||||
},
|
||||
}),
|
||||
[promptGroupsNav, allGroupsData, isLoadingAll],
|
||||
);
|
||||
|
||||
return (
|
||||
<PromptGroupsContext.Provider value={contextValue}>{children}</PromptGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePromptGroupsContext = () => {
|
||||
const context = useContext(PromptGroupsContext);
|
||||
if (!context) {
|
||||
throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -23,4 +23,6 @@ export * from './SetConvoContext';
|
||||
export * from './SearchContext';
|
||||
export * from './BadgeRowContext';
|
||||
export * from './SidePanelContext';
|
||||
export * from './ArtifactsContext';
|
||||
export * from './PromptGroupsContext';
|
||||
export { default as BadgeRowProvider } from './BadgeRowContext';
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useInfiniteScroll } from '~/hooks/useInfiniteScroll';
|
||||
import { useHasData } from './SmartLoader';
|
||||
import ErrorDisplay from './ErrorDisplay';
|
||||
import AgentCard from './AgentCard';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
|
||||
@@ -9,6 +9,7 @@ import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
|
||||
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { MarketplaceProvider } from './MarketplaceContext';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
@@ -301,8 +302,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* Admin Settings */}
|
||||
<div className="absolute right-4 top-4 z-30">
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
|
||||
211
client/src/components/Agents/MarketplaceAdminSettings.tsx
Normal file
211
client/src/components/Agents/MarketplaceAdminSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.USE]: boolean;
|
||||
};
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
marketplacePerm: Permissions.USE;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
marketplacePerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(marketplacePerm, !getValues(marketplacePerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={marketplacePerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MarketplaceAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
|
||||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.MARKETPLACE];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData: {
|
||||
marketplacePerm: Permissions.USE;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
marketplacePerm: Permissions.USE,
|
||||
label: localize('com_ui_marketplace_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_marketplace',
|
||||
)}`}</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ marketplacePerm, label }) => (
|
||||
<div key={marketplacePerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
marketplacePerm={marketplacePerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketplaceAdminSettings;
|
||||
@@ -48,7 +48,7 @@ describe('AgentCard', () => {
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -248,8 +248,9 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should show skeleton loading state
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
// Should show loading spinner
|
||||
const spinner = document.querySelector('.text-primary');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no agents are available', () => {
|
||||
@@ -310,7 +311,8 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
|
||||
// The component doesn't show search result titles, just displays the filtered agents
|
||||
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty search results message', () => {
|
||||
@@ -336,8 +338,7 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('CategoryTabs', () => {
|
||||
);
|
||||
|
||||
const generalTab = screen.getByText('General').closest('button');
|
||||
expect(generalTab).toHaveClass('bg-surface-tertiary');
|
||||
expect(generalTab).toHaveClass('bg-surface-hover');
|
||||
|
||||
// Should have active underline
|
||||
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||
|
||||
@@ -3,37 +3,93 @@ import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { jest } from '@jest/globals';
|
||||
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
|
||||
// Mock react-virtualized for performance testing
|
||||
const mockRowRenderer = jest.fn();
|
||||
jest.mock('react-virtualized', () => ({
|
||||
AutoSizer: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||
}) => children({ width: 1200, height: 800 }),
|
||||
List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => {
|
||||
mockRowRenderer.mockImplementation(rowRenderer);
|
||||
// Only render visible rows to simulate virtualization
|
||||
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
|
||||
return (
|
||||
<div data-testid="virtual-list" data-total-rows={rowCount}>
|
||||
{Array.from({ length: visibleRows }, (_, index) =>
|
||||
rowRenderer({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
style: { height: 184 },
|
||||
parent: { props: { width: 1200 } },
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-virtualized', () => {
|
||||
const mockRowRendererRef = { current: jest.fn() };
|
||||
|
||||
return {
|
||||
AutoSizer: ({
|
||||
children,
|
||||
disableHeight,
|
||||
}: {
|
||||
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||
disableHeight?: boolean;
|
||||
}) => {
|
||||
if (disableHeight) {
|
||||
return children({ width: 1200 });
|
||||
}
|
||||
return children({ width: 1200, height: 800 });
|
||||
},
|
||||
List: ({
|
||||
rowRenderer,
|
||||
rowCount,
|
||||
autoHeight,
|
||||
height,
|
||||
width,
|
||||
rowHeight,
|
||||
overscanRowCount,
|
||||
scrollTop,
|
||||
isScrolling,
|
||||
onScroll,
|
||||
style,
|
||||
'aria-rowcount': ariaRowCount,
|
||||
'data-testid': dataTestId,
|
||||
'data-total-rows': dataTotalRows,
|
||||
}: {
|
||||
rowRenderer: any;
|
||||
rowCount: number;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
// Store the row renderer for testing
|
||||
if (typeof rowRenderer === 'function') {
|
||||
mockRowRendererRef.current = rowRenderer;
|
||||
mockRowRenderer.mockImplementation(rowRenderer);
|
||||
}
|
||||
// Only render visible rows to simulate virtualization
|
||||
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
|
||||
return (
|
||||
<div
|
||||
data-testid={dataTestId || 'virtual-list'}
|
||||
data-total-rows={dataTotalRows || rowCount}
|
||||
aria-rowcount={ariaRowCount}
|
||||
style={style}
|
||||
>
|
||||
{Array.from({ length: visibleRows }, (_, index) =>
|
||||
rowRenderer({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
style: { height: 184 },
|
||||
parent: { props: { width: width || 1200 } },
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
WindowScroller: ({
|
||||
children,
|
||||
scrollElement,
|
||||
}: {
|
||||
children: (props: any) => React.ReactNode;
|
||||
scrollElement?: HTMLElement | null;
|
||||
}) => {
|
||||
return children({
|
||||
height: 800,
|
||||
isScrolling: false,
|
||||
registerChild: (ref: any) => {},
|
||||
onChildScroll: () => {},
|
||||
scrollTop: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Generate large dataset for performance testing
|
||||
const generateLargeDataset = (count: number) => {
|
||||
const agents = [];
|
||||
const agents: Partial<t.Agent>[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
agents.push({
|
||||
id: `agent-${i}`,
|
||||
@@ -59,9 +115,9 @@ const createMockInfiniteQuery = (agentCount: number) => ({
|
||||
isFetchingNextPage: false,
|
||||
});
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
// Mock must be hoisted before imports
|
||||
jest.mock('~/data-provider/Agents', () => ({
|
||||
useMarketplaceAgentsInfiniteQuery: mockUseQuery,
|
||||
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAgentCategories: () => ({
|
||||
@@ -82,12 +138,6 @@ jest.mock('../SmartLoader', () => ({
|
||||
useHasData: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/useInfiniteScroll', () => ({
|
||||
useInfiniteScroll: () => ({
|
||||
setScrollElement: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../AgentCard', () => {
|
||||
return function MockAgentCard({ agent }: { agent: any }) {
|
||||
return (
|
||||
@@ -114,7 +164,12 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
const renderComponent = (agentCount: number) => {
|
||||
const mockQuery = createMockInfiniteQuery(agentCount);
|
||||
mockUseQuery.mockReturnValue(mockQuery);
|
||||
const useMarketplaceAgentsInfiniteQuery =
|
||||
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||
useMarketplaceAgentsInfiniteQuery.mockReturnValue(mockQuery);
|
||||
|
||||
// Clear previous mock calls
|
||||
mockRowRenderer.mockClear();
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -139,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Performance check: rendering should be fast
|
||||
const renderTime = endTime - startTime;
|
||||
expect(renderTime).toBeLessThan(100); // Should render in less than 100ms
|
||||
expect(renderTime).toBeLessThan(600); // Should render in less than 600ms
|
||||
|
||||
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||
@@ -176,12 +231,20 @@ describe('Virtual Scrolling Performance', () => {
|
||||
});
|
||||
|
||||
it('row renderer is called efficiently', () => {
|
||||
// Reset the mock before testing
|
||||
mockRowRenderer.mockClear();
|
||||
|
||||
renderComponent(1000);
|
||||
|
||||
// Row renderer should be called, but not 500 times (since we virtualize)
|
||||
expect(mockRowRenderer).toHaveBeenCalled();
|
||||
expect(mockRowRenderer.mock.calls.length).toBeLessThan(100);
|
||||
expect(mockRowRenderer.mock.calls.length).toBeGreaterThan(0);
|
||||
// Check that virtual list was rendered
|
||||
const virtualList = screen.getByTestId('virtual-list');
|
||||
expect(virtualList).toBeInTheDocument();
|
||||
|
||||
// With virtualization, we should only render visible rows
|
||||
// Our mock renders 10 visible rows max
|
||||
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||
expect(renderedCards.length).toBeLessThanOrEqual(20); // At most 10 rows * 2 cards per row
|
||||
expect(renderedCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('memory usage remains stable with large datasets', () => {
|
||||
@@ -202,8 +265,8 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Memory usage should not scale linearly with data size
|
||||
// All should render roughly the same number of DOM nodes
|
||||
expect(Math.abs(memory100 - memory1000)).toBeLessThan(10);
|
||||
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(10);
|
||||
expect(Math.abs(memory100 - memory1000)).toBeLessThan(30);
|
||||
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(30);
|
||||
|
||||
console.log(
|
||||
`Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`,
|
||||
|
||||
@@ -9,21 +9,70 @@ import type t from 'librechat-data-provider';
|
||||
jest.mock('react-virtualized', () => ({
|
||||
AutoSizer: ({
|
||||
children,
|
||||
disableHeight,
|
||||
}: {
|
||||
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||
}) => children({ width: 800, height: 600 }),
|
||||
List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => (
|
||||
<div data-testid="virtual-list">
|
||||
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||
disableHeight?: boolean;
|
||||
}) => {
|
||||
if (disableHeight) {
|
||||
return children({ width: 800 });
|
||||
}
|
||||
return children({ width: 800, height: 600 });
|
||||
},
|
||||
List: ({
|
||||
rowRenderer,
|
||||
rowCount,
|
||||
width,
|
||||
style,
|
||||
'aria-rowcount': ariaRowCount,
|
||||
'data-testid': dataTestId,
|
||||
'data-total-rows': dataTotalRows,
|
||||
}: {
|
||||
rowRenderer: any;
|
||||
rowCount: number;
|
||||
autoHeight?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
rowHeight?: number;
|
||||
overscanRowCount?: number;
|
||||
scrollTop?: number;
|
||||
isScrolling?: boolean;
|
||||
onScroll?: any;
|
||||
style?: any;
|
||||
'aria-rowcount'?: number;
|
||||
'data-testid'?: string;
|
||||
'data-total-rows'?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-testid={dataTestId || 'virtual-list'}
|
||||
aria-rowcount={ariaRowCount}
|
||||
data-total-rows={dataTotalRows}
|
||||
style={style}
|
||||
>
|
||||
{Array.from({ length: Math.min(rowCount, 5) }, (_, index) =>
|
||||
rowRenderer({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
style: {},
|
||||
parent: { props: { width: 800 } },
|
||||
parent: { props: { width: width || 800 } },
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
WindowScroller: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: any) => React.ReactNode;
|
||||
scrollElement?: HTMLElement | null;
|
||||
}) => {
|
||||
return children({
|
||||
height: 600,
|
||||
isScrolling: false,
|
||||
registerChild: (_ref: any) => {},
|
||||
onChildScroll: () => {},
|
||||
scrollTop: 0,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the data provider
|
||||
@@ -81,12 +130,6 @@ jest.mock('../SmartLoader', () => ({
|
||||
useHasData: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/useInfiniteScroll', () => ({
|
||||
useInfiniteScroll: () => ({
|
||||
setScrollElement: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../AgentCard', () => {
|
||||
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
|
||||
return (
|
||||
@@ -175,20 +218,31 @@ describe('VirtualizedAgentGrid', () => {
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Should show loading spinner instead of skeleton
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
// Should show loading spinner
|
||||
const spinner = document.querySelector('.spinner');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('h-8 w-8 text-primary');
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', async () => {
|
||||
// Reset the mock to ensure we have data
|
||||
const useMarketplaceAgentsInfiniteQuery =
|
||||
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||
useMarketplaceAgentsInfiniteQuery.mockImplementation(() => mockInfiniteQuery);
|
||||
|
||||
renderComponent({ category: 'productivity' });
|
||||
|
||||
await waitFor(() => {
|
||||
const gridContainer = screen.getByRole('grid');
|
||||
expect(gridContainer).toHaveAttribute('aria-label');
|
||||
|
||||
const tabpanel = screen.getByRole('tabpanel');
|
||||
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
|
||||
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
|
||||
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const gridContainer = screen.getByRole('grid');
|
||||
expect(gridContainer).toHaveAttribute('aria-label');
|
||||
expect(gridContainer.getAttribute('aria-label')).toContain('2');
|
||||
expect(gridContainer.getAttribute('aria-label')).toContain('Productivity');
|
||||
|
||||
const tabpanel = screen.getByRole('tabpanel');
|
||||
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
|
||||
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { memo, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
useSandpack,
|
||||
SandpackCodeEditor,
|
||||
@@ -10,8 +10,8 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
|
||||
const createDebouncedMutation = (
|
||||
callback: (params: {
|
||||
@@ -29,18 +29,21 @@ const CodeEditor = ({
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly: boolean;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: () => {
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
@@ -71,8 +74,14 @@ const CodeEditor = ({
|
||||
}
|
||||
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
const isNotOriginal =
|
||||
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdate == null
|
||||
? true
|
||||
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
|
||||
|
||||
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
|
||||
if (artifact.content && isNotOriginal && isNotRepeated) {
|
||||
setCurrentCode(currentCode);
|
||||
debouncedMutation({
|
||||
index: artifact.index,
|
||||
@@ -92,8 +101,9 @@ const CodeEditor = ({
|
||||
artifact.messageId,
|
||||
readOnly,
|
||||
isMutating,
|
||||
sandpack.files,
|
||||
currentUpdate,
|
||||
setIsMutating,
|
||||
sandpack.files,
|
||||
setCurrentCode,
|
||||
debouncedMutation,
|
||||
]);
|
||||
@@ -102,33 +112,32 @@ const CodeEditor = ({
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
readOnly={readOnly}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArtifactCodeEditor = memo(function ({
|
||||
export const ArtifactCodeEditor = function ({
|
||||
files,
|
||||
fileKey,
|
||||
template,
|
||||
artifact,
|
||||
editorRef,
|
||||
sharedProps,
|
||||
isSubmitting,
|
||||
}: {
|
||||
fileKey: string;
|
||||
artifact: Artifact;
|
||||
files: ArtifactFiles;
|
||||
isSubmitting: boolean;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const options: typeof sharedOptions = useMemo(() => {
|
||||
if (!config) {
|
||||
return sharedOptions;
|
||||
@@ -138,6 +147,10 @@ export const ArtifactCodeEditor = memo(function ({
|
||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config, template]);
|
||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
||||
useEffect(() => {
|
||||
setReadOnly(isSubmitting ?? false);
|
||||
}, [isSubmitting]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
@@ -154,12 +167,7 @@ export const ArtifactCodeEditor = memo(function ({
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
<CodeEditor
|
||||
editorRef={editorRef}
|
||||
fileKey={fileKey}
|
||||
readOnly={isSubmitting}
|
||||
artifact={artifact}
|
||||
/>
|
||||
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
|
||||
</StyledProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useRef, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
@@ -15,14 +15,13 @@ export default function ArtifactTabs({
|
||||
isMermaid,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSubmitting,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
isMermaid: boolean;
|
||||
isSubmitting: boolean;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const { currentCode, setCurrentCode } = useEditorContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
@@ -52,7 +51,6 @@ export default function ArtifactTabs({
|
||||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function Artifacts() {
|
||||
isMermaid,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
isSubmitting,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
@@ -116,7 +115,6 @@ export default function Artifacts() {
|
||||
<ArtifactTabs
|
||||
isMermaid={isMermaid}
|
||||
artifact={currentArtifact}
|
||||
isSubmitting={isSubmitting}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OpenIDIcon } from '@librechat/client';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||
import SocialButton from '~/components/Auth/SocialButton';
|
||||
@@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
|
||||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
@@ -21,6 +23,19 @@ function Login() {
|
||||
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
|
||||
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
|
||||
|
||||
useEffect(() => {
|
||||
const oauthError = searchParams?.get('error');
|
||||
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
|
||||
showToast({
|
||||
message: localize('com_auth_error_oauth_failed'),
|
||||
status: 'error',
|
||||
});
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('error');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams, showToast, localize]);
|
||||
|
||||
// Once the disable flag is detected, update local state and remove the parameter from the URL.
|
||||
useEffect(() => {
|
||||
if (disableAutoRedirect) {
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
|
||||
import { removeCharIfLast, detectVariables } from '~/utils';
|
||||
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useGetAllPromptGroups } from '~/data-provider';
|
||||
import MentionItem from './MentionItem';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -60,30 +59,8 @@ function PromptsCommand({
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useGetAllPromptGroups(undefined, {
|
||||
enabled: hasAccess,
|
||||
select: (data) => {
|
||||
const mappedArray = data.map((group) => ({
|
||||
id: group._id,
|
||||
value: group.command ?? group.name,
|
||||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||
group.name
|
||||
}: ${
|
||||
(group.oneliner?.length ?? 0) > 0
|
||||
? group.oneliner
|
||||
: (group.productionPrompt?.prompt ?? '')
|
||||
}`,
|
||||
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
||||
}));
|
||||
|
||||
const promptsMap = mapPromptGroups(data);
|
||||
|
||||
return {
|
||||
promptsMap,
|
||||
promptGroups: mappedArray,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { allPromptGroups } = usePromptGroupsContext();
|
||||
const { data, isLoading } = allPromptGroups;
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
|
||||
import { EditorProvider, SidePanelProvider } from '~/Providers';
|
||||
import { EditorProvider, SidePanelProvider, ArtifactsProvider } from '~/Providers';
|
||||
import Artifacts from '~/components/Artifacts/Artifacts';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { useSetFilesToDelete } from '~/hooks';
|
||||
@@ -66,9 +66,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
artifacts={
|
||||
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
<ArtifactsProvider>
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
</ArtifactsProvider>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TPromptGroup } from 'librechat-data-provider';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
|
||||
export default function PromptCard({ promptGroup }: { promptGroup?: TPromptGroup }) {
|
||||
return (
|
||||
<div className="hover:bg-token-main-surface-secondary relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-slate-100 dark:border-gray-600 dark:hover:bg-gray-700">
|
||||
<div className="">
|
||||
<CategoryIcon className="size-4" category={promptGroup?.category ?? ''} />
|
||||
</div>
|
||||
<p className="break-word line-clamp-3 text-balance text-gray-600 dark:text-gray-400">
|
||||
{(promptGroup?.oneliner ?? '') || promptGroup?.productionPrompt?.prompt}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
import PromptCard from './PromptCard';
|
||||
import { Button } from '../ui';
|
||||
|
||||
export default function Prompts() {
|
||||
const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } =
|
||||
usePromptGroupsNav();
|
||||
|
||||
const renderPromptCards = (start = 0, count) => {
|
||||
return promptGroups
|
||||
.slice(start, count + start)
|
||||
.map((promptGroup) => <PromptCard key={promptGroup._id} promptGroup={promptGroup} />);
|
||||
};
|
||||
|
||||
const getRows = () => {
|
||||
switch (pageSize) {
|
||||
case 4:
|
||||
return [4];
|
||||
case 8:
|
||||
return [4, 4];
|
||||
case 12:
|
||||
return [4, 4, 4];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const rows = getRows();
|
||||
|
||||
return (
|
||||
<div className="mx-3 flex h-full max-w-3xl flex-col items-stretch justify-center gap-4">
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => setPageSize(4)}
|
||||
className={`rounded px-3 py-2 hover:bg-transparent ${
|
||||
pageSize === 4 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
4
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => setPageSize(8)}
|
||||
className={`rounded px-3 py-2 hover:bg-transparent ${
|
||||
pageSize === 8 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
8
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => setPageSize(12)}
|
||||
className={`rounded p-2 hover:bg-transparent ${
|
||||
pageSize === 12 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
12
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-start gap-2">
|
||||
<div
|
||||
className={
|
||||
'flex min-h-[121.1px] min-w-full max-w-3xl flex-col gap-4 overflow-y-auto md:min-w-[22rem] lg:min-w-[43rem]'
|
||||
}
|
||||
>
|
||||
{rows.map((rowSize, index) => (
|
||||
<div key={index} className="flex flex-wrap justify-center gap-4">
|
||||
{renderPromptCards(rowSize * index, rowSize)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full justify-between">
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={prevPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className="m-0 self-start p-0 hover:bg-transparent"
|
||||
aria-label="previous"
|
||||
>
|
||||
<ChevronLeft className={`${hasPreviousPage ? '' : 'text-gray-500'}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={nextPage}
|
||||
disabled={!hasNextPage}
|
||||
className="m-0 self-end p-0 hover:bg-transparent"
|
||||
>
|
||||
<ChevronRight className={`${hasNextPage ? '' : 'text-gray-500'}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type EndpointIcon = {
|
||||
|
||||
function getOpenAIColor(_model: string | null | undefined) {
|
||||
const model = _model?.toLowerCase() ?? '';
|
||||
if (model && /\b(o\d)\b/i.test(model)) {
|
||||
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) {
|
||||
return '#000000';
|
||||
}
|
||||
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';
|
||||
|
||||
@@ -32,13 +32,14 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<TooltipAnchor
|
||||
enableHTML={true}
|
||||
description={config.description || ''}
|
||||
render={
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={name} className="text-sm font-medium">
|
||||
{config.title}
|
||||
</Label>
|
||||
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />
|
||||
<CircleHelpIcon className="h-6 w-6 cursor-help text-text-secondary transition-colors hover:text-text-primary" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
|
||||
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
|
||||
import type { TOpenAIMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { formatJSON, extractJson, isJson } from '~/utils/json';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -25,7 +24,7 @@ type TTokenBalance = {
|
||||
prev_count: number;
|
||||
violation_count: number;
|
||||
date: Date;
|
||||
generations?: TOpenAIMessage[];
|
||||
generations?: unknown[];
|
||||
};
|
||||
|
||||
type TExpiredKey = {
|
||||
@@ -44,6 +43,17 @@ const errorMessages = {
|
||||
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
|
||||
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
|
||||
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
|
||||
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_missing_model', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
|
||||
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
|
||||
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
|
||||
const { expiredAt, endpoint } = json;
|
||||
@@ -65,6 +75,12 @@ const errorMessages = {
|
||||
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
|
||||
[ViolationTypes.BAN]:
|
||||
'Your account has been temporarily banned due to violations of our service.',
|
||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info } = json;
|
||||
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
|
||||
},
|
||||
invalid_api_key:
|
||||
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
|
||||
insufficient_quota:
|
||||
|
||||
@@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
@@ -17,12 +17,12 @@ function Account() {
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
{user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{user?.user?.twoFactorEnabled && (
|
||||
{user?.twoFactorEnabled && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [_disableToken, setDisableToken] = useState<string>('');
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
|
||||
@@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
disable2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
[disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -153,7 +153,7 @@ const AdminSettings = () => {
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-lg border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`}
|
||||
</OGDialogTitle>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SystemCategories } from 'librechat-data-provider';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
|
||||
import type { Option } from '~/common';
|
||||
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
|
||||
import { useLocalize, useCategories, usePromptGroupsNav } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMediaQuery } from '@librechat/client';
|
||||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||
import List from '~/components/Prompts/Groups/List';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
import type { usePromptGroupsNav } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function GroupSidePanel({
|
||||
|
||||
@@ -6,7 +6,12 @@ import { Menu, Rocket } from 'lucide-react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { useParams, useOutletContext } from 'react-router-dom';
|
||||
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
||||
import { Permissions, PermissionTypes, PermissionBits } from 'librechat-data-provider';
|
||||
import {
|
||||
Permissions,
|
||||
ResourceType,
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
|
||||
import {
|
||||
useGetPrompts,
|
||||
@@ -173,16 +178,14 @@ const PromptForm = () => {
|
||||
const [showSidePanel, setShowSidePanel] = useState(false);
|
||||
const sidePanelWidth = '320px';
|
||||
|
||||
// Fetch group early so it is available for later hooks.
|
||||
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
|
||||
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
|
||||
{ groupId: promptId },
|
||||
{ enabled: !!promptId },
|
||||
);
|
||||
|
||||
// Check permissions for the promptGroup
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
'promptGroup',
|
||||
ResourceType.PROMPTGROUP,
|
||||
group?._id || '',
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
|
||||
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
|
||||
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
|
||||
export default function PromptsAccordion() {
|
||||
const groupsNav = usePromptGroupsNav();
|
||||
const groupsNav = usePromptGroupsContext();
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
|
||||
|
||||
@@ -3,14 +3,15 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
||||
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
|
||||
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
|
||||
import GroupSidePanel from './Groups/GroupSidePanel';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function PromptsView() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const groupsNav = usePromptGroupsNav();
|
||||
const groupsNav = usePromptGroupsContext();
|
||||
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn, getRoleLocalizationKeys } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AccessRolesPickerProps {
|
||||
id?: string;
|
||||
resourceType?: ResourceType;
|
||||
selectedRoleId?: AccessRoleIds;
|
||||
onRoleChange: (roleId: AccessRoleIds) => void;
|
||||
@@ -17,6 +18,7 @@ interface AccessRolesPickerProps {
|
||||
}
|
||||
|
||||
export default function AccessRolesPicker({
|
||||
id,
|
||||
resourceType = ResourceType.AGENT,
|
||||
selectedRoleId = AccessRoleIds.AGENT_VIEWER,
|
||||
onRoleChange,
|
||||
@@ -63,7 +65,7 @@ export default function AccessRolesPicker({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={className} id={id}>
|
||||
<DropdownPopup
|
||||
menuId="access-roles-menu"
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
|
||||
import PeoplePickerAdminSettings from './PeoplePickerAdminSettings';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import { SelectedPrincipalsList } from './PeoplePicker';
|
||||
import { cn } from '~/utils';
|
||||
@@ -125,13 +126,6 @@ export default function GenericGrantAccessDialog({
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// Handler for revoking all sharing
|
||||
const handleRevokeAll = () => {
|
||||
setAllShares([]);
|
||||
setIsPublic(false);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// Handler for public access toggle
|
||||
const handlePublicToggle = (isPublicValue: boolean) => {
|
||||
setIsPublic(isPublicValue);
|
||||
@@ -373,7 +367,8 @@ export default function GenericGrantAccessDialog({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2">
|
||||
<PeoplePickerAdminSettings />
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
|
||||
@@ -2,10 +2,10 @@ import React, { useState, useMemo } from 'react';
|
||||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize, usePeoplePickerPermissions } from '~/hooks';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||
import { SearchPicker } from './SearchPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PeoplePickerProps {
|
||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||
@@ -21,7 +21,6 @@ export default function PeoplePicker({
|
||||
typeFilter = null,
|
||||
}: PeoplePickerProps) {
|
||||
const localize = useLocalize();
|
||||
const { canViewUsers, canViewGroups, canViewRoles } = usePeoplePickerPermissions();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||
|
||||
@@ -56,28 +55,6 @@ export default function PeoplePicker({
|
||||
console.error('Principal search error:', error);
|
||||
}
|
||||
|
||||
/** Get appropriate label based on permissions */
|
||||
const getSearchLabel = () => {
|
||||
const permissions = [canViewUsers, canViewGroups, canViewRoles];
|
||||
const permissionCount = permissions.filter(Boolean).length;
|
||||
|
||||
if (permissionCount === 3) {
|
||||
return localize('com_ui_search_users_groups_roles');
|
||||
} else if (permissionCount === 2) {
|
||||
if (canViewUsers && canViewGroups) {
|
||||
return localize('com_ui_search_users_groups');
|
||||
}
|
||||
} else if (canViewUsers) {
|
||||
return localize('com_ui_search_users');
|
||||
} else if (canViewGroups) {
|
||||
return localize('com_ui_search_groups');
|
||||
} else if (canViewRoles) {
|
||||
return localize('com_ui_search_roles');
|
||||
}
|
||||
|
||||
return localize('com_ui_search_users_groups');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="relative">
|
||||
|
||||
@@ -14,7 +14,7 @@ type SearchPickerProps<TOption extends { key: string }> = {
|
||||
onPick: (pickedOption: TOption) => void;
|
||||
placeholder?: string;
|
||||
inputClassName?: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
resetValueOnHide?: boolean;
|
||||
isSmallScreen?: boolean;
|
||||
isLoading?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import type { TPrincipal, PrincipalType, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import { SearchPicker } from './SearchPicker';
|
||||
@@ -9,8 +9,8 @@ interface UnifiedPeopleSearchProps {
|
||||
onAddPeople: (principals: TPrincipal[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
typeFilter?: 'user' | 'group' | null;
|
||||
excludeIds?: string[];
|
||||
typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null;
|
||||
excludeIds?: (string | undefined)[];
|
||||
}
|
||||
|
||||
export default function UnifiedPeopleSearch({
|
||||
|
||||
221
client/src/components/Sharing/PeoplePickerAdminSettings.tsx
Normal file
221
client/src/components/Sharing/PeoplePickerAdminSettings.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { useUpdatePeoplePickerPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.VIEW_USERS]: boolean;
|
||||
[Permissions.VIEW_GROUPS]: boolean;
|
||||
[Permissions.VIEW_ROLES]: boolean;
|
||||
};
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
peoplePickerPerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(peoplePickerPerm, !getValues(peoplePickerPerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={peoplePickerPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PeoplePickerAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdatePeoplePickerPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
|
||||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.PEOPLE_PICKER];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.PEOPLE_PICKER];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.PEOPLE_PICKER];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.PEOPLE_PICKER]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData: {
|
||||
peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_USERS,
|
||||
label: localize('com_ui_people_picker_allow_view_users'),
|
||||
},
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_GROUPS,
|
||||
label: localize('com_ui_people_picker_allow_view_groups'),
|
||||
},
|
||||
{
|
||||
peoplePickerPerm: Permissions.VIEW_ROLES,
|
||||
label: localize('com_ui_people_picker_allow_view_roles'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_people_picker',
|
||||
)}`}</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ peoplePickerPerm, label }) => (
|
||||
<div key={peoplePickerPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
peoplePickerPerm={peoplePickerPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PeoplePickerAdminSettings;
|
||||
@@ -9,7 +9,7 @@ import { cn } from '~/utils';
|
||||
|
||||
interface PublicSharingToggleProps {
|
||||
isPublic: boolean;
|
||||
publicRole: AccessRoleIds;
|
||||
publicRole?: AccessRoleIds;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: AccessRoleIds) => void;
|
||||
resourceType?: ResourceType;
|
||||
|
||||
@@ -157,7 +157,7 @@ const AdminSettings = () => {
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_agents',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
385
client/src/components/SidePanel/Agents/AgentPanel.test.tsx
Normal file
385
client/src/components/SidePanel/Agents/AgentPanel.test.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
|
||||
// Mock toast context - define this after all mocks
|
||||
let mockShowToast: jest.Mock;
|
||||
|
||||
// Mock notification severity enum before other imports
|
||||
jest.mock('~/common/types', () => ({
|
||||
NotificationSeverity: {
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error',
|
||||
INFO: 'info',
|
||||
WARNING: 'warning',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock store to prevent import errors
|
||||
jest.mock('~/store/toast', () => ({
|
||||
default: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/store', () => {});
|
||||
|
||||
// Mock the data service to control network responses
|
||||
jest.mock('librechat-data-provider', () => {
|
||||
const actualModule = jest.requireActual('librechat-data-provider') as any;
|
||||
return {
|
||||
...actualModule,
|
||||
dataService: {
|
||||
updateAgent: jest.fn(),
|
||||
},
|
||||
Tools: {
|
||||
execute_code: 'execute_code',
|
||||
file_search: 'file_search',
|
||||
web_search: 'web_search',
|
||||
},
|
||||
Constants: {
|
||||
EPHEMERAL_AGENT_ID: 'ephemeral',
|
||||
},
|
||||
SystemRoles: {
|
||||
ADMIN: 'ADMIN',
|
||||
},
|
||||
EModelEndpoint: {
|
||||
agents: 'agents',
|
||||
chatGPTBrowser: 'chatGPTBrowser',
|
||||
gptPlugins: 'gptPlugins',
|
||||
},
|
||||
isAssistantsEndpoint: jest.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
useToastContext: () => ({
|
||||
get showToast() {
|
||||
return mockShowToast || jest.fn();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetModelsQuery: () => ({ data: {} }),
|
||||
useGetEffectivePermissionsQuery: () => ({
|
||||
data: { permissionBits: 0xffffffff }, // All permissions
|
||||
isLoading: false,
|
||||
}),
|
||||
hasPermissions: (_bits: number, _required: number) => true, // Always return true for tests
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
createProviderOption: jest.fn((provider: string) => ({ value: provider, label: provider })),
|
||||
getDefaultAgentFormValues: jest.fn(() => ({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
model: '',
|
||||
provider: '',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useSelectAgent: () => ({ onSelect: jest.fn() }),
|
||||
useLocalize: () => (key: string) => key,
|
||||
useAuthContext: () => ({ user: { id: 'user-123', role: 'USER' } }),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/AgentPanelContext', () => ({
|
||||
useAgentPanelContext: () => ({
|
||||
activePanel: 'builder',
|
||||
agentsConfig: { allowedProviders: [] },
|
||||
setActivePanel: jest.fn(),
|
||||
endpointsConfig: {},
|
||||
setCurrentAgentId: jest.fn(),
|
||||
agent_id: 'agent-123',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/common', () => ({
|
||||
Panel: {
|
||||
model: 'model',
|
||||
builder: 'builder',
|
||||
advanced: 'advanced',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child components to simplify testing
|
||||
jest.mock('./AgentPanelSkeleton', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>{`Loading...`}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./Advanced/AdvancedPanel', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>{`Advanced Panel`}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./AgentConfig', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>{`Agent Config`}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./AgentSelect', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>{`Agent Select`}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./ModelPanel', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>{`Model Panel`}</div>,
|
||||
}));
|
||||
|
||||
// Mock AgentFooter to provide a save button
|
||||
jest.mock('./AgentFooter', () => ({
|
||||
__esModule: true,
|
||||
default: () => (
|
||||
<button type="submit" data-testid="save-agent-button">
|
||||
{`Save Agent`}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-hook-form to capture form submission
|
||||
let mockFormSubmitHandler: (() => void) | null = null;
|
||||
|
||||
jest.mock('react-hook-form', () => {
|
||||
const actual = jest.requireActual('react-hook-form') as any;
|
||||
return {
|
||||
...actual,
|
||||
useForm: () => {
|
||||
const methods = actual.useForm({
|
||||
defaultValues: {
|
||||
id: 'agent-123',
|
||||
name: 'Test Agent',
|
||||
description: 'Test description',
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
tools: [],
|
||||
execute_code: false,
|
||||
file_search: false,
|
||||
web_search: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...methods,
|
||||
handleSubmit: (onSubmit: any) => (e?: any) => {
|
||||
e?.preventDefault?.();
|
||||
mockFormSubmitHandler = () => onSubmit(methods.getValues());
|
||||
return mockFormSubmitHandler;
|
||||
},
|
||||
};
|
||||
},
|
||||
FormProvider: ({ children }: any) => children,
|
||||
useWatch: () => 'agent-123',
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
import { dataService } from 'librechat-data-provider';
|
||||
import { useGetAgentByIdQuery } from '~/data-provider';
|
||||
import AgentPanel from './AgentPanel';
|
||||
|
||||
// Mock useGetAgentByIdQuery
|
||||
jest.mock('~/data-provider', () => {
|
||||
const actual = jest.requireActual('~/data-provider') as any;
|
||||
return {
|
||||
...actual,
|
||||
useGetAgentByIdQuery: jest.fn(),
|
||||
useUpdateAgentMutation: actual.useUpdateAgentMutation,
|
||||
};
|
||||
});
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Test helpers
|
||||
const setupMocks = () => {
|
||||
const mockUseGetAgentByIdQuery = useGetAgentByIdQuery as jest.MockedFunction<
|
||||
typeof useGetAgentByIdQuery
|
||||
>;
|
||||
const mockUpdateAgent = dataService.updateAgent as jest.MockedFunction<
|
||||
typeof dataService.updateAgent
|
||||
>;
|
||||
|
||||
return { mockUseGetAgentByIdQuery, mockUpdateAgent };
|
||||
};
|
||||
|
||||
const mockAgentQuery = (
|
||||
mockUseGetAgentByIdQuery: jest.MockedFunction<typeof useGetAgentByIdQuery>,
|
||||
agent: Partial<Agent>,
|
||||
) => {
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||
data: {
|
||||
id: 'agent-123',
|
||||
author: 'user-123',
|
||||
isCollaborative: false,
|
||||
...agent,
|
||||
} as Agent,
|
||||
isInitialLoading: false,
|
||||
} as any);
|
||||
};
|
||||
|
||||
const createMockAgent = (overrides: Partial<Agent> = {}): Agent =>
|
||||
({
|
||||
id: 'agent-123',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
...overrides,
|
||||
}) as Agent;
|
||||
|
||||
const renderAndSubmitForm = async () => {
|
||||
const Wrapper = createWrapper();
|
||||
const { container, rerender } = render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
const form = container.querySelector('form');
|
||||
expect(form).toBeTruthy();
|
||||
|
||||
fireEvent.submit(form!);
|
||||
|
||||
if (mockFormSubmitHandler) {
|
||||
mockFormSubmitHandler();
|
||||
}
|
||||
|
||||
return { container, rerender, form };
|
||||
};
|
||||
|
||||
describe('AgentPanel - Update Agent Toast Messages', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockShowToast = jest.fn();
|
||||
mockFormSubmitHandler = null;
|
||||
});
|
||||
|
||||
describe('AgentPanel', () => {
|
||||
it('should show "no changes" toast when version does not change', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
// Mock the agent query with version 2
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 2,
|
||||
});
|
||||
|
||||
// Mock network response - same version
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 2 }));
|
||||
|
||||
await renderAndSubmitForm();
|
||||
|
||||
// Wait for the toast to be shown
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_no_changes',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "update success" toast when version changes', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
// Mock the agent query with version 2
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 2,
|
||||
});
|
||||
|
||||
// Mock network response - different version
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 3 }));
|
||||
|
||||
await renderAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_assistants_update_success Test Agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "update success" with default name when agent has no name', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
// Mock the agent query without name
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// Mock network response - no name
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ version: 2 }));
|
||||
|
||||
await renderAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_assistants_update_success com_ui_agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "update success" when agent query has no version (undefined)', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
// Mock the agent query with no version data
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
// No version property
|
||||
});
|
||||
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ name: 'Test Agent', version: 1 }));
|
||||
|
||||
await renderAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_assistants_update_success Test Agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast on update failure', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
// Mock the agent query
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {
|
||||
name: 'Test Agent',
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// Mock network error
|
||||
mockUpdateAgent.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await renderAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_agents_update_error com_ui_error: Update failed',
|
||||
status: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useMemo, useCallback, useRef } from 'react';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { useWatch, useForm, FormProvider } from 'react-hook-form';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
@@ -76,6 +76,7 @@ export default function AgentPanel() {
|
||||
|
||||
const { control, handleSubmit, reset } = methods;
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
const previousVersionRef = useRef<number | undefined>();
|
||||
|
||||
const allowedProviders = useMemo(
|
||||
() => new Set(agentsConfig?.allowedProviders),
|
||||
@@ -99,50 +100,29 @@ export default function AgentPanel() {
|
||||
|
||||
/* Mutations */
|
||||
const update = useUpdateAgentMutation({
|
||||
onMutate: () => {
|
||||
// Store the current version before mutation
|
||||
previousVersionRef.current = agentQuery.data?.version;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
});
|
||||
// Check if agent version is the same (no changes were made)
|
||||
if (previousVersionRef.current !== undefined && data.version === previousVersionRef.current) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_changes'),
|
||||
status: 'info',
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
});
|
||||
}
|
||||
// Clear the ref after use
|
||||
previousVersionRef.current = undefined;
|
||||
},
|
||||
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: 'warning',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
|
||||
@@ -146,7 +146,7 @@ const AdminSettings = () => {
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_memories',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
@@ -47,11 +47,7 @@ export const useCreateAgentMutation = (
|
||||
*/
|
||||
export const useUpdateAgentMutation = (
|
||||
options?: t.UpdateAgentMutationOptions,
|
||||
): UseMutationResult<
|
||||
t.Agent,
|
||||
t.DuplicateVersionError,
|
||||
{ agent_id: string; data: t.AgentUpdateParams }
|
||||
> => {
|
||||
): UseMutationResult<t.Agent, Error, { agent_id: string; data: t.AgentUpdateParams }> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({ agent_id, data }: { agent_id: string; data: t.AgentUpdateParams }) => {
|
||||
@@ -63,8 +59,7 @@ export const useUpdateAgentMutation = (
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => {
|
||||
const typedError = error as t.DuplicateVersionError;
|
||||
return options?.onError?.(typedError, variables, context);
|
||||
return options?.onError?.(error, variables, context);
|
||||
},
|
||||
onSuccess: (updatedAgent, variables, context) => {
|
||||
((keys: t.AgentListParams[]) => {
|
||||
|
||||
@@ -134,12 +134,12 @@ export const useConfirmTwoFactorMutation = (): UseMutationResult<
|
||||
export const useDisableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TDisable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
t.TDisable2FARequest | undefined,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.disableTwoFactor(), {
|
||||
onSuccess: (data) => {
|
||||
return useMutation((payload?: t.TDisable2FARequest) => dataService.disableTwoFactor(payload), {
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
dataService,
|
||||
promptPermissionsSchema,
|
||||
memoryPermissionsSchema,
|
||||
marketplacePermissionsSchema,
|
||||
peoplePickerPermissionsSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
UseQueryOptions,
|
||||
@@ -132,3 +134,75 @@ export const useUpdateMemoryPermissionsMutation = (
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdatePeoplePickerPermissionsMutation = (
|
||||
options?: t.UpdatePeoplePickerPermOptions,
|
||||
): UseMutationResult<
|
||||
t.UpdatePermResponse,
|
||||
t.TError | undefined,
|
||||
t.UpdatePeoplePickerPermVars,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onMutate, onSuccess, onError } = options ?? {};
|
||||
return useMutation(
|
||||
(variables) => {
|
||||
peoplePickerPermissionsSchema.partial().parse(variables.updates);
|
||||
return dataService.updatePeoplePickerPermissions(variables);
|
||||
},
|
||||
{
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries([QueryKeys.roles, variables.roleName]);
|
||||
if (onSuccess) {
|
||||
onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (...args) => {
|
||||
const error = args[0];
|
||||
if (error != null) {
|
||||
console.error('Failed to update people picker permissions:', error);
|
||||
}
|
||||
if (onError) {
|
||||
onError(...args);
|
||||
}
|
||||
},
|
||||
onMutate,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateMarketplacePermissionsMutation = (
|
||||
options?: t.UpdateMarketplacePermOptions,
|
||||
): UseMutationResult<
|
||||
t.UpdatePermResponse,
|
||||
t.TError | undefined,
|
||||
t.UpdateMarketplacePermVars,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onMutate, onSuccess, onError } = options ?? {};
|
||||
return useMutation(
|
||||
(variables) => {
|
||||
marketplacePermissionsSchema.partial().parse(variables.updates);
|
||||
return dataService.updateMarketplacePermissions(variables);
|
||||
},
|
||||
{
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries([QueryKeys.roles, variables.roleName]);
|
||||
if (onSuccess) {
|
||||
onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (...args) => {
|
||||
const error = args[0];
|
||||
if (error != null) {
|
||||
console.error('Failed to update marketplace permissions:', error);
|
||||
}
|
||||
if (onError) {
|
||||
onError(...args);
|
||||
}
|
||||
},
|
||||
onMutate,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { getLatestText, logger } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { logger } from '~/utils';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { getKey } from '~/utils/artifacts';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useArtifacts() {
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
||||
const { isSubmitting, latestMessageId, latestMessageText, conversationId } =
|
||||
useArtifactsContext();
|
||||
|
||||
const artifacts = useRecoilValue(store.artifactsState);
|
||||
const resetArtifacts = useResetRecoilState(store.artifactsState);
|
||||
@@ -31,26 +32,23 @@ export default function useArtifacts() {
|
||||
const resetState = () => {
|
||||
resetArtifacts();
|
||||
resetCurrentArtifactId();
|
||||
prevConversationIdRef.current = conversation?.conversationId ?? null;
|
||||
prevConversationIdRef.current = conversationId;
|
||||
lastRunMessageIdRef.current = null;
|
||||
lastContentRef.current = null;
|
||||
hasEnclosedArtifactRef.current = false;
|
||||
};
|
||||
if (
|
||||
conversation?.conversationId !== prevConversationIdRef.current &&
|
||||
prevConversationIdRef.current != null
|
||||
) {
|
||||
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
|
||||
resetState();
|
||||
} else if (conversation?.conversationId === Constants.NEW_CONVO) {
|
||||
} else if (conversationId === Constants.NEW_CONVO) {
|
||||
resetState();
|
||||
}
|
||||
prevConversationIdRef.current = conversation?.conversationId ?? null;
|
||||
prevConversationIdRef.current = conversationId;
|
||||
/** Resets artifacts when unmounting */
|
||||
return () => {
|
||||
logger.log('artifacts_visibility', 'Unmounting artifacts');
|
||||
resetState();
|
||||
};
|
||||
}, [conversation?.conversationId, resetArtifacts, resetCurrentArtifactId]);
|
||||
}, [conversationId, resetArtifacts, resetCurrentArtifactId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderedArtifactIds.length > 0) {
|
||||
@@ -66,7 +64,7 @@ export default function useArtifacts() {
|
||||
if (orderedArtifactIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (latestMessage == null) {
|
||||
if (latestMessageId == null) {
|
||||
return;
|
||||
}
|
||||
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
|
||||
@@ -78,7 +76,6 @@ export default function useArtifacts() {
|
||||
setCurrentArtifactId(latestArtifactId);
|
||||
lastContentRef.current = latestArtifact?.content ?? null;
|
||||
|
||||
const latestMessageText = getLatestText(latestMessage);
|
||||
const hasEnclosedArtifact =
|
||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||
latestMessageText.trim(),
|
||||
@@ -95,15 +92,22 @@ export default function useArtifacts() {
|
||||
hasAutoSwitchedToCodeRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [setCurrentArtifactId, isSubmitting, orderedArtifactIds, artifacts, latestMessage]);
|
||||
}, [
|
||||
artifacts,
|
||||
isSubmitting,
|
||||
latestMessageId,
|
||||
latestMessageText,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMessage?.messageId !== lastRunMessageIdRef.current) {
|
||||
lastRunMessageIdRef.current = latestMessage?.messageId ?? null;
|
||||
if (latestMessageId !== lastRunMessageIdRef.current) {
|
||||
lastRunMessageIdRef.current = latestMessageId;
|
||||
hasEnclosedArtifactRef.current = false;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
}
|
||||
}, [latestMessage]);
|
||||
}, [latestMessageId]);
|
||||
|
||||
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
|
||||
|
||||
@@ -131,7 +135,6 @@ export default function useArtifacts() {
|
||||
isMermaid,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
isSubmitting,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
|
||||
@@ -81,7 +81,9 @@ export function useMCPServerManager() {
|
||||
return initialStates;
|
||||
});
|
||||
|
||||
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
|
||||
const { data: connectionStatusData } = useMCPConnectionStatusQuery({
|
||||
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
|
||||
});
|
||||
const connectionStatus = useMemo(
|
||||
() => connectionStatusData?.connectionStatus || {},
|
||||
[connectionStatusData?.connectionStatus],
|
||||
@@ -158,6 +160,8 @@ export function useMCPServerManager() {
|
||||
setMCPValues([...currentValues, serverName]);
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries([QueryKeys.tools]);
|
||||
|
||||
// This delay is to ensure UI has updated with new connection status before cleanup
|
||||
// Otherwise servers will show as disconnected for a second after OAuth flow completes
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
||||
import debounce from 'lodash/debounce';
|
||||
import store from '~/store';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import debounce from 'lodash/debounce';
|
||||
import store from '~/store';
|
||||
|
||||
export default function usePromptGroupsNav() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -14,6 +14,7 @@ export default function usePromptGroupsNav() {
|
||||
const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber);
|
||||
|
||||
const maxPageNumberReached = useRef(1);
|
||||
const prevFiltersRef = useRef({ name, category, pageSize });
|
||||
|
||||
useEffect(() => {
|
||||
if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) {
|
||||
@@ -29,10 +30,25 @@ export default function usePromptGroupsNav() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const filtersChanged =
|
||||
prevFiltersRef.current.name !== name ||
|
||||
prevFiltersRef.current.category !== category ||
|
||||
prevFiltersRef.current.pageSize !== pageSize;
|
||||
|
||||
if (!filtersChanged) {
|
||||
return;
|
||||
}
|
||||
maxPageNumberReached.current = 1;
|
||||
setPageNumber(1);
|
||||
queryClient.resetQueries([QueryKeys.promptGroups, name, category, pageSize]);
|
||||
}, [pageSize, name, category, setPageNumber]);
|
||||
|
||||
// Only reset queries if we're not already on page 1
|
||||
// This prevents double queries when filters change
|
||||
if (pageNumber !== 1) {
|
||||
queryClient.invalidateQueries([QueryKeys.promptGroups, name, category, pageSize]);
|
||||
}
|
||||
|
||||
prevFiltersRef.current = { name, category, pageSize };
|
||||
}, [pageSize, name, category, setPageNumber, pageNumber, queryClient]);
|
||||
|
||||
const promptGroups = useMemo(() => {
|
||||
return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || [];
|
||||
@@ -52,10 +68,11 @@ export default function usePromptGroupsNav() {
|
||||
const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber;
|
||||
const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1;
|
||||
|
||||
const debouncedSetName = useCallback(
|
||||
debounce((nextValue: string) => {
|
||||
setName(nextValue);
|
||||
}, 850),
|
||||
const debouncedSetName = useMemo(
|
||||
() =>
|
||||
debounce((nextValue: string) => {
|
||||
setName(nextValue);
|
||||
}, 850),
|
||||
[setName],
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user