Compare commits
9 Commits
main
...
refactor/A
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924276d4b9 | ||
|
|
b64265e5bf | ||
|
|
438392f705 | ||
|
|
c925f9f39c | ||
|
|
71effb1a66 | ||
|
|
e3acd18c07 | ||
|
|
d14a063302 | ||
|
|
a36426ef54 | ||
|
|
edcac7669b |
@@ -32,11 +32,15 @@ class AzureAISearch extends Tool {
|
|||||||
fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
|
fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
|
||||||
'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
|
'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
|
||||||
);
|
);
|
||||||
this.indexName = this._initializeField(
|
// Get the indexes as a comma-separated string
|
||||||
|
this.indexNames = this._initializeField(
|
||||||
fields.AZURE_AI_SEARCH_INDEX_NAME,
|
fields.AZURE_AI_SEARCH_INDEX_NAME,
|
||||||
'AZURE_AI_SEARCH_INDEX_NAME',
|
'AZURE_AI_SEARCH_INDEX_NAME',
|
||||||
);
|
);
|
||||||
this.apiKey = this._initializeField(fields.AZURE_AI_SEARCH_API_KEY, 'AZURE_AI_SEARCH_API_KEY');
|
this.apiKey = this._initializeField(
|
||||||
|
fields.AZURE_AI_SEARCH_API_KEY,
|
||||||
|
'AZURE_AI_SEARCH_API_KEY',
|
||||||
|
);
|
||||||
this.apiVersion = this._initializeField(
|
this.apiVersion = this._initializeField(
|
||||||
fields.AZURE_AI_SEARCH_API_VERSION,
|
fields.AZURE_AI_SEARCH_API_VERSION,
|
||||||
'AZURE_AI_SEARCH_API_VERSION',
|
'AZURE_AI_SEARCH_API_VERSION',
|
||||||
@@ -58,7 +62,7 @@ class AzureAISearch extends Tool {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check for required fields
|
// Check for required fields
|
||||||
if (!this.override && (!this.serviceEndpoint || !this.indexName || !this.apiKey)) {
|
if (!this.override && (!this.serviceEndpoint || !this.indexNames || !this.apiKey)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
|
'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
|
||||||
);
|
);
|
||||||
@@ -68,12 +72,25 @@ class AzureAISearch extends Tool {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create SearchClient
|
// Split the indexNames by comma to support multiple indexes, trim whitespace,
|
||||||
this.client = new SearchClient(
|
// convert to lowercase, and filter out any empty strings.
|
||||||
this.serviceEndpoint,
|
const indexes = this.indexNames
|
||||||
this.indexName,
|
.split(',')
|
||||||
new AzureKeyCredential(this.apiKey),
|
.map(index => index.trim().toLowerCase())
|
||||||
{ apiVersion: this.apiVersion },
|
.filter(index => index.length > 0);
|
||||||
|
|
||||||
|
if (indexes.length === 0) {
|
||||||
|
throw new Error('No valid index names provided in AZURE_AI_SEARCH_INDEX_NAME.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a client for each index.
|
||||||
|
this.clients = indexes.map(index =>
|
||||||
|
new SearchClient(
|
||||||
|
this.serviceEndpoint,
|
||||||
|
index,
|
||||||
|
new AzureKeyCredential(this.apiKey),
|
||||||
|
{ apiVersion: this.apiVersion },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +105,21 @@ class AzureAISearch extends Tool {
|
|||||||
if (this.select) {
|
if (this.select) {
|
||||||
searchOption.select = this.select.split(',');
|
searchOption.select = this.select.split(',');
|
||||||
}
|
}
|
||||||
const searchResults = await this.client.search(query, searchOption);
|
|
||||||
const resultDocuments = [];
|
// Query all indexes concurrently
|
||||||
for await (const result of searchResults.results) {
|
const searchPromises = this.clients.map(async (client) => {
|
||||||
resultDocuments.push(result.document);
|
const resultDocuments = [];
|
||||||
}
|
const searchResults = await client.search(query, searchOption);
|
||||||
return JSON.stringify(resultDocuments);
|
for await (const result of searchResults.results) {
|
||||||
|
resultDocuments.push(result.document);
|
||||||
|
}
|
||||||
|
return resultDocuments;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all search promises to complete and flatten the results
|
||||||
|
const resultsByIndex = await Promise.all(searchPromises);
|
||||||
|
const combinedResults = resultsByIndex.flat();
|
||||||
|
return JSON.stringify(combinedResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Azure AI Search request failed', error);
|
logger.error('Azure AI Search request failed', error);
|
||||||
return 'There was an error with Azure AI Search.';
|
return 'There was an error with Azure AI Search.';
|
||||||
|
|||||||
@@ -21,7 +21,19 @@ const Agent = mongoose.model('agent', agentSchema);
|
|||||||
* @throws {Error} If the agent creation fails.
|
* @throws {Error} If the agent creation fails.
|
||||||
*/
|
*/
|
||||||
const createAgent = async (agentData) => {
|
const createAgent = async (agentData) => {
|
||||||
return (await Agent.create(agentData)).toObject();
|
const { versions, ...versionData } = agentData;
|
||||||
|
const timestamp = new Date();
|
||||||
|
const initialAgentData = {
|
||||||
|
...agentData,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
...versionData,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +115,8 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.author.toString() === req.user.id) {
|
if (agent.author.toString() === req.user.id) {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
@@ -127,18 +141,146 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a version already exists in the versions array, excluding timestamp and author fields
|
||||||
|
* @param {Object} updateData - The update data to compare
|
||||||
|
* @param {Array} versions - The existing versions array
|
||||||
|
* @returns {Object|null} - The matching version if found, null otherwise
|
||||||
|
*/
|
||||||
|
const isDuplicateVersion = (updateData, currentData, versions) => {
|
||||||
|
if (!versions || versions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludeFields = [
|
||||||
|
'_id',
|
||||||
|
'id',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'author',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'__v',
|
||||||
|
'agent_ids',
|
||||||
|
'versions',
|
||||||
|
];
|
||||||
|
|
||||||
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
|
if (Object.keys(directUpdates).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wouldBeVersion = { ...currentData, ...directUpdates };
|
||||||
|
const lastVersion = versions[versions.length - 1];
|
||||||
|
|
||||||
|
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
|
||||||
|
|
||||||
|
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
|
||||||
|
|
||||||
|
let isMatch = true;
|
||||||
|
for (const field of importantFields) {
|
||||||
|
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||||
|
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for projectIds (MongoDB ObjectIds)
|
||||||
|
if (field === 'projectIds') {
|
||||||
|
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||||
|
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||||
|
|
||||||
|
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle arrays of objects like tool_kwargs
|
||||||
|
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (field === 'model_parameters') {
|
||||||
|
const wouldBeParams = wouldBeVersion[field] || {};
|
||||||
|
const lastVersionParams = lastVersion[field] || {};
|
||||||
|
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMatch ? lastVersion : null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an agent with new data without overwriting existing
|
* Update an agent with new data without overwriting existing
|
||||||
* properties, or create a new agent if it doesn't exist.
|
* properties, or create a new agent if it doesn't exist.
|
||||||
|
* When an agent is updated, a copy of the current state will be saved to the versions array.
|
||||||
*
|
*
|
||||||
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
||||||
* @param {string} searchParameter.id - The ID of the agent to update.
|
* @param {string} searchParameter.id - The ID of the agent to update.
|
||||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||||
* @param {Object} updateData - An object containing the properties to update.
|
* @param {Object} updateData - An object containing the properties to update.
|
||||||
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
||||||
|
* @throws {Error} If the update would create a duplicate version
|
||||||
*/
|
*/
|
||||||
const updateAgent = async (searchParameter, updateData) => {
|
const updateAgent = async (searchParameter, updateData) => {
|
||||||
const options = { new: true, upsert: false };
|
const options = { new: true, upsert: false };
|
||||||
|
|
||||||
|
const currentAgent = await Agent.findOne(searchParameter);
|
||||||
|
if (currentAgent) {
|
||||||
|
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
|
||||||
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
|
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
|
||||||
|
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
|
||||||
|
if (duplicateVersion) {
|
||||||
|
const error = new Error(
|
||||||
|
'Duplicate version: This would create a version identical to an existing one',
|
||||||
|
);
|
||||||
|
error.statusCode = 409;
|
||||||
|
error.details = {
|
||||||
|
duplicateVersion,
|
||||||
|
versionIndex: versions.findIndex(
|
||||||
|
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.$push = {
|
||||||
|
...($push || {}),
|
||||||
|
versions: {
|
||||||
|
...versionData,
|
||||||
|
...directUpdates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,6 +500,38 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
|||||||
return await getAgent({ id: agentId });
|
return await getAgent({ id: agentId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a specific version in its version history.
|
||||||
|
* @param {Object} searchParameter - The search parameters to find the agent to revert.
|
||||||
|
* @param {string} searchParameter.id - The ID of the agent to revert.
|
||||||
|
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||||
|
* @param {number} versionIndex - The index of the version to revert to in the versions array.
|
||||||
|
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
|
||||||
|
* @throws {Error} If the agent is not found or the specified version does not exist.
|
||||||
|
*/
|
||||||
|
const revertAgentVersion = async (searchParameter, versionIndex) => {
|
||||||
|
const agent = await Agent.findOne(searchParameter);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error('Agent not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.versions || !agent.versions[versionIndex]) {
|
||||||
|
throw new Error(`Version ${versionIndex} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revertToVersion = agent.versions[versionIndex];
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
...revertToVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
delete updateData._id;
|
||||||
|
delete updateData.id;
|
||||||
|
delete updateData.versions;
|
||||||
|
|
||||||
|
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Agent,
|
Agent,
|
||||||
getAgent,
|
getAgent,
|
||||||
@@ -369,4 +543,5 @@ module.exports = {
|
|||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
|
revertAgentVersion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
|
const originalEnv = {
|
||||||
|
CREDS_KEY: process.env.CREDS_KEY,
|
||||||
|
CREDS_IV: process.env.CREDS_IV,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
||||||
|
process.env.CREDS_IV = '0123456789abcdef';
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
|
const {
|
||||||
|
Agent,
|
||||||
|
addAgentResourceFile,
|
||||||
|
removeAgentResourceFiles,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
getAgent,
|
||||||
|
deleteAgent,
|
||||||
|
getListAgents,
|
||||||
|
updateAgentProjects,
|
||||||
|
} = require('./Agent');
|
||||||
|
|
||||||
describe('Agent Resource File Operations', () => {
|
describe('Agent Resource File Operations', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
@@ -15,6 +33,8 @@ describe('Agent Resource File Operations', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
await mongoServer.stop();
|
await mongoServer.stop();
|
||||||
|
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
|
||||||
|
process.env.CREDS_IV = originalEnv.CREDS_IV;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -332,3 +352,537 @@ describe('Agent Resource File Operations', () => {
|
|||||||
expect(finalFileIds).toHaveLength(0);
|
expect(finalFileIds).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Agent CRUD Operations', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create and get an agent', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const newAgent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newAgent).toBeDefined();
|
||||||
|
expect(newAgent.id).toBe(agentId);
|
||||||
|
expect(newAgent.name).toBe('Test Agent');
|
||||||
|
|
||||||
|
const retrievedAgent = await getAgent({ id: agentId });
|
||||||
|
expect(retrievedAgent).toBeDefined();
|
||||||
|
expect(retrievedAgent.id).toBe(agentId);
|
||||||
|
expect(retrievedAgent.name).toBe('Test Agent');
|
||||||
|
expect(retrievedAgent.description).toBe('Test description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete an agent', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentBeforeDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentBeforeDelete).toBeDefined();
|
||||||
|
|
||||||
|
await deleteAgent({ id: agentId });
|
||||||
|
|
||||||
|
const agentAfterDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentAfterDelete).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should list agents by author', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const agentIds = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const id = `agent_${uuidv4()}`;
|
||||||
|
agentIds.push(id);
|
||||||
|
await createAgent({
|
||||||
|
id,
|
||||||
|
name: `Agent ${i}`,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await createAgent({
|
||||||
|
id: `other_agent_${uuidv4()}`,
|
||||||
|
name: `Other Agent ${i}`,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: otherAuthorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getListAgents({ author: authorId.toString() });
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
expect(result.data).toHaveLength(5);
|
||||||
|
expect(result.has_more).toBe(true);
|
||||||
|
|
||||||
|
for (const agent of result.data) {
|
||||||
|
expect(agent.author).toBe(authorId.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update agent projects', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId1 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId2 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId3 = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Project Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
projectIds: [projectId1],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
|
||||||
|
|
||||||
|
const updatedAgent = await getAgent({ id: agentId });
|
||||||
|
expect(updatedAgent.projectIds).toHaveLength(2);
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [] });
|
||||||
|
|
||||||
|
const emptyProjectsAgent = await getAgent({ id: agentId });
|
||||||
|
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
|
||||||
|
|
||||||
|
const nonExistentId = `agent_${uuidv4()}`;
|
||||||
|
await expect(
|
||||||
|
updateAgentProjects({
|
||||||
|
id: nonExistentId,
|
||||||
|
projectIds: [projectId1],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle ephemeral agent loading', async () => {
|
||||||
|
const agentId = 'ephemeral_test';
|
||||||
|
const endpoint = 'openai';
|
||||||
|
|
||||||
|
const originalModule = jest.requireActual('librechat-data-provider');
|
||||||
|
|
||||||
|
const mockDataProvider = {
|
||||||
|
...originalModule,
|
||||||
|
Constants: {
|
||||||
|
...originalModule.Constants,
|
||||||
|
EPHEMERAL_AGENT_ID: 'ephemeral_test',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.doMock('librechat-data-provider', () => mockDataProvider);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { id: 'user123' },
|
||||||
|
body: {
|
||||||
|
promptPrefix: 'This is a test instruction',
|
||||||
|
ephemeralAgent: {
|
||||||
|
execute_code: true,
|
||||||
|
mcp: ['server1', 'server2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
locals: {
|
||||||
|
availableTools: {
|
||||||
|
tool__server1: {},
|
||||||
|
tool__server2: {},
|
||||||
|
another_tool: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
req: mockReq,
|
||||||
|
agent_id: agentId,
|
||||||
|
endpoint,
|
||||||
|
model_parameters: {
|
||||||
|
model: 'gpt-4',
|
||||||
|
temperature: 0.7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(agentId).toBeDefined();
|
||||||
|
expect(endpoint).toBeDefined();
|
||||||
|
|
||||||
|
jest.dontMock('librechat-data-provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle loadAgent functionality and errors', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Load Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await getAgent({ id: agentId });
|
||||||
|
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent.id).toBe(agentId);
|
||||||
|
expect(agent.name).toBe('Test Load Agent');
|
||||||
|
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
|
||||||
|
|
||||||
|
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
|
||||||
|
const loadedAgent = await mockLoadAgent();
|
||||||
|
expect(loadedAgent).toBeDefined();
|
||||||
|
expect(loadedAgent.id).toBe(agentId);
|
||||||
|
|
||||||
|
const nonExistentId = `agent_${uuidv4()}`;
|
||||||
|
const nonExistentAgent = await getAgent({ id: nonExistentId });
|
||||||
|
expect(nonExistentAgent).toBeNull();
|
||||||
|
|
||||||
|
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
|
||||||
|
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Agent Version History', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create an agent with a single entry in versions array', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.versions).toBeDefined();
|
||||||
|
expect(Array.isArray(agent.versions)).toBe(true);
|
||||||
|
expect(agent.versions).toHaveLength(1);
|
||||||
|
expect(agent.versions[0].name).toBe('Test Agent');
|
||||||
|
expect(agent.versions[0].provider).toBe('test');
|
||||||
|
expect(agent.versions[0].model).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accumulate version history across multiple updates', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const author = new mongoose.Types.ObjectId();
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'First Name',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author,
|
||||||
|
description: 'First description',
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
|
||||||
|
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
|
||||||
|
|
||||||
|
expect(finalAgent.versions).toBeDefined();
|
||||||
|
expect(Array.isArray(finalAgent.versions)).toBe(true);
|
||||||
|
expect(finalAgent.versions).toHaveLength(4);
|
||||||
|
|
||||||
|
expect(finalAgent.versions[0].name).toBe('First Name');
|
||||||
|
expect(finalAgent.versions[0].description).toBe('First description');
|
||||||
|
expect(finalAgent.versions[0].model).toBe('test-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[1].name).toBe('Second Name');
|
||||||
|
expect(finalAgent.versions[1].description).toBe('Second description');
|
||||||
|
expect(finalAgent.versions[1].model).toBe('test-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[2].name).toBe('Third Name');
|
||||||
|
expect(finalAgent.versions[2].description).toBe('Second description');
|
||||||
|
expect(finalAgent.versions[2].model).toBe('new-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[3].name).toBe('Third Name');
|
||||||
|
expect(finalAgent.versions[3].description).toBe('Final description');
|
||||||
|
expect(finalAgent.versions[3].model).toBe('new-model');
|
||||||
|
|
||||||
|
expect(finalAgent.name).toBe('Third Name');
|
||||||
|
expect(finalAgent.description).toBe('Final description');
|
||||||
|
expect(finalAgent.model).toBe('new-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not include metadata fields in version history', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
|
||||||
|
|
||||||
|
expect(updatedAgent.versions).toHaveLength(2);
|
||||||
|
expect(updatedAgent.versions[0]._id).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[0].__v).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[0].name).toBe('Test Agent');
|
||||||
|
expect(updatedAgent.versions[0].author).toBeDefined();
|
||||||
|
|
||||||
|
expect(updatedAgent.versions[1]._id).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[1].__v).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not recursively include previous versions', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
|
||||||
|
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
|
||||||
|
|
||||||
|
expect(finalAgent.versions).toHaveLength(4);
|
||||||
|
|
||||||
|
finalAgent.versions.forEach((version) => {
|
||||||
|
expect(version.versions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle MongoDB operators and field updates correctly', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'MongoDB Operator Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
tools: ['tool1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
description: 'Updated description',
|
||||||
|
$push: { tools: 'tool2' },
|
||||||
|
$addToSet: { projectIds: projectId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstUpdate = await getAgent({ id: agentId });
|
||||||
|
expect(firstUpdate.description).toBe('Updated description');
|
||||||
|
expect(firstUpdate.tools).toContain('tool1');
|
||||||
|
expect(firstUpdate.tools).toContain('tool2');
|
||||||
|
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
|
||||||
|
expect(firstUpdate.versions).toHaveLength(2);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
tools: ['tool2', 'tool3'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondUpdate = await getAgent({ id: agentId });
|
||||||
|
expect(secondUpdate.tools).toHaveLength(2);
|
||||||
|
expect(secondUpdate.tools).toContain('tool2');
|
||||||
|
expect(secondUpdate.tools).toContain('tool3');
|
||||||
|
expect(secondUpdate.tools).not.toContain('tool1');
|
||||||
|
expect(secondUpdate.versions).toHaveLength(3);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
$push: { tools: 'tool3' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const thirdUpdate = await getAgent({ id: agentId });
|
||||||
|
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
|
||||||
|
expect(toolCount).toBe(2);
|
||||||
|
expect(thirdUpdate.versions).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle parameter objects correctly', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Parameters Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
model_parameters: { temperature: 0.7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAgent = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{ model_parameters: { temperature: 0.8 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updatedAgent.versions).toHaveLength(2);
|
||||||
|
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
model_parameters: {
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const complexAgent = await getAgent({ id: agentId });
|
||||||
|
expect(complexAgent.versions).toHaveLength(3);
|
||||||
|
expect(complexAgent.model_parameters.temperature).toBe(0.8);
|
||||||
|
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { model_parameters: {} });
|
||||||
|
|
||||||
|
const emptyParamsAgent = await getAgent({ id: agentId });
|
||||||
|
expect(emptyParamsAgent.versions).toHaveLength(4);
|
||||||
|
expect(emptyParamsAgent.model_parameters).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should detect duplicate versions and reject updates', async () => {
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId1 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId2 = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: 'simple field update',
|
||||||
|
initial: {
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Initial description',
|
||||||
|
},
|
||||||
|
update: { name: 'Updated Name' },
|
||||||
|
duplicate: { name: 'Updated Name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'object field update',
|
||||||
|
initial: {
|
||||||
|
model_parameters: { temperature: 0.7 },
|
||||||
|
},
|
||||||
|
update: { model_parameters: { temperature: 0.8 } },
|
||||||
|
duplicate: { model_parameters: { temperature: 0.8 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'array field update',
|
||||||
|
initial: {
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
},
|
||||||
|
update: { tools: ['tool2', 'tool3'] },
|
||||||
|
duplicate: { tools: ['tool2', 'tool3'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectIds update',
|
||||||
|
initial: {
|
||||||
|
projectIds: [projectId1],
|
||||||
|
},
|
||||||
|
update: { projectIds: [projectId1, projectId2] },
|
||||||
|
duplicate: { projectIds: [projectId2, projectId1] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const testAgentId = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: testAgentId,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
...testCase.initial,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: testAgentId }, testCase.update);
|
||||||
|
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toContain('Duplicate version');
|
||||||
|
expect(error.statusCode).toBe(409);
|
||||||
|
expect(error.details).toBeDefined();
|
||||||
|
expect(error.details.duplicateVersion).toBeDefined();
|
||||||
|
|
||||||
|
const agent = await getAgent({ id: testAgentId });
|
||||||
|
expect(agent.versions).toHaveLength(2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { updateAction, getActions } = require('~/models/Action');
|
|||||||
const { updateAgentProjects } = require('~/models/Agent');
|
const { updateAgentProjects } = require('~/models/Agent');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
|
const { revertAgentVersion } = require('~/models/Agent');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
@@ -104,6 +105,8 @@ const getAgentHandler = async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Agent not found' });
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||||
const originalUrl = agent.avatar.filepath;
|
const originalUrl = agent.avatar.filepath;
|
||||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||||
@@ -127,6 +130,7 @@ const getAgentHandler = async (req, res) => {
|
|||||||
author: agent.author,
|
author: agent.author,
|
||||||
projectIds: agent.projectIds,
|
projectIds: agent.projectIds,
|
||||||
isCollaborative: agent.isCollaborative,
|
isCollaborative: agent.isCollaborative,
|
||||||
|
version: agent.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.status(200).json(agent);
|
return res.status(200).json(agent);
|
||||||
@@ -187,6 +191,14 @@ const updateAgentHandler = async (req, res) => {
|
|||||||
return res.json(updatedAgent);
|
return res.json(updatedAgent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
logger.error('[/Agents/:id] Error updating Agent', error);
|
||||||
|
|
||||||
|
if (error.statusCode === 409) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -411,6 +423,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a previous version from its version history.
|
||||||
|
* @route PATCH /agents/:id/revert
|
||||||
|
* @param {object} req - Express Request object
|
||||||
|
* @param {object} req.params - Request parameters
|
||||||
|
* @param {string} req.params.id - The ID of the agent to revert
|
||||||
|
* @param {object} req.body - Request body
|
||||||
|
* @param {number} req.body.version_index - The index of the version to revert to
|
||||||
|
* @param {object} req.user - Authenticated user information
|
||||||
|
* @param {string} req.user.id - User ID
|
||||||
|
* @param {string} req.user.role - User role
|
||||||
|
* @param {ServerResponse} res - Express Response object
|
||||||
|
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
||||||
|
* @throws {Error} 400 - If version_index is missing
|
||||||
|
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
||||||
|
* @throws {Error} 404 - If agent not found
|
||||||
|
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
||||||
|
*/
|
||||||
|
const revertAgentVersionHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { version_index } = req.body;
|
||||||
|
|
||||||
|
if (version_index === undefined) {
|
||||||
|
return res.status(400).json({ error: 'version_index is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||||
|
const existingAgent = await getAgent({ id });
|
||||||
|
|
||||||
|
if (!existingAgent) {
|
||||||
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||||
|
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||||
|
|
||||||
|
if (!hasEditPermission) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'You do not have permission to modify this non-collaborative agent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||||
|
|
||||||
|
if (updatedAgent.author) {
|
||||||
|
updatedAgent.author = updatedAgent.author.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedAgent.author !== req.user.id) {
|
||||||
|
delete updatedAgent.author;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(updatedAgent);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAgent: createAgentHandler,
|
createAgent: createAgentHandler,
|
||||||
getAgent: getAgentHandler,
|
getAgent: getAgentHandler,
|
||||||
@@ -419,4 +491,5 @@ module.exports = {
|
|||||||
deleteAgent: deleteAgentHandler,
|
deleteAgent: deleteAgentHandler,
|
||||||
getListAgents: getListAgentsHandler,
|
getListAgents: getListAgentsHandler,
|
||||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||||
|
revertAgentVersion: revertAgentVersionHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
|||||||
*/
|
*/
|
||||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a previous version.
|
||||||
|
* @route POST /agents/:id/revert
|
||||||
|
* @param {string} req.params.id - Agent identifier.
|
||||||
|
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||||
|
* @returns {Agent} 200 - success response - application/json
|
||||||
|
*/
|
||||||
|
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of agents.
|
* Returns a list of agents.
|
||||||
* @route GET /agents
|
* @route GET /agents
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function loadTurnstileConfig(config, configDefaults) {
|
|||||||
options: customTurnstile.options ?? defaults.options,
|
options: customTurnstile.options ?? defaults.options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const enabled = Boolean(loadedTurnstile.siteKey);
|
const enabled = Boolean(loadedTurnstile.siteKey);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -36,6 +37,7 @@ function loadTurnstileConfig(config, configDefaults) {
|
|||||||
logger.info('Turnstile is DISABLED (no siteKey provided).');
|
logger.info('Turnstile is DISABLED (no siteKey provided).');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return loadedTurnstile;
|
return loadedTurnstile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export enum Panel {
|
|||||||
builder = 'builder',
|
builder = 'builder',
|
||||||
actions = 'actions',
|
actions = 'actions',
|
||||||
model = 'model',
|
model = 'model',
|
||||||
|
version = 'version',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileSetter =
|
export type FileSetter =
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type TLoginFormProps = {
|
|||||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
|
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { theme } = useContext(ThemeContext);
|
const { theme } = useContext(ThemeContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
getValues,
|
getValues,
|
||||||
@@ -29,8 +28,10 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||||||
const { data: config } = useGetStartupConfig();
|
const { data: config } = useGetStartupConfig();
|
||||||
const useUsernameLogin = config?.ldap?.username;
|
const useUsernameLogin = config?.ldap?.username;
|
||||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
|
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && error.includes('422') && !showResendLink) {
|
if (error && error.includes('422') && !showResendLink) {
|
||||||
setShowResendLink(true);
|
setShowResendLink(true);
|
||||||
@@ -150,6 +151,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{requireCaptcha && (
|
{requireCaptcha && (
|
||||||
<div className="my-4 flex justify-center">
|
<div className="my-4 flex justify-center">
|
||||||
<Turnstile
|
<Turnstile
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const Registration: React.FC = () => {
|
|||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
const token = queryParams.get('token');
|
const token = queryParams.get('token');
|
||||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
// only require captcha if we have a siteKey
|
// only require captcha if we have a siteKey
|
||||||
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
|
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
|
||||||
|
|
||||||
@@ -179,6 +178,7 @@ const Registration: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{startupConfig?.turnstile?.siteKey && (
|
{startupConfig?.turnstile?.siteKey && (
|
||||||
|
|
||||||
<div className="my-4 flex justify-center">
|
<div className="my-4 flex justify-center">
|
||||||
<Turnstile
|
<Turnstile
|
||||||
siteKey={startupConfig.turnstile.siteKey}
|
siteKey={startupConfig.turnstile.siteKey}
|
||||||
@@ -198,6 +198,7 @@ const Registration: React.FC = () => {
|
|||||||
disabled={
|
disabled={
|
||||||
Object.keys(errors).length > 0 ||
|
Object.keys(errors).length > 0 ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
|
|
||||||
(requireCaptcha && !turnstileToken)
|
(requireCaptcha && !turnstileToken)
|
||||||
}
|
}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useWatch, useFormContext } from 'react-hook-form';
|
import { useWatch, useFormContext } from 'react-hook-form';
|
||||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||||
@@ -11,6 +10,7 @@ import DeleteButton from './DeleteButton';
|
|||||||
import { Spinner } from '~/components';
|
import { Spinner } from '~/components';
|
||||||
import ShareAgent from './ShareAgent';
|
import ShareAgent from './ShareAgent';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
import VersionButton from './Version/VersionButton';
|
||||||
|
|
||||||
export default function AgentFooter({
|
export default function AgentFooter({
|
||||||
activePanel,
|
activePanel,
|
||||||
@@ -53,8 +53,10 @@ export default function AgentFooter({
|
|||||||
const showButtons = activePanel === Panel.builder;
|
const showButtons = activePanel === Panel.builder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="mb-1 flex w-full flex-col gap-2">
|
<div className="mb-1 flex w-full flex-col gap-2">
|
||||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||||
|
{showButtons && agent_id && <VersionButton setActivePanel={setActivePanel} />}
|
||||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||||
{/* Context Button */}
|
{/* Context Button */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -87,7 +87,42 @@ export default function AgentPanel({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const error = err as Error;
|
const error = err as Error & {
|
||||||
|
statusCode?: number;
|
||||||
|
details?: { duplicateVersion?: any; versionIndex?: number };
|
||||||
|
response?: { status?: number; data?: any };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDuplicateVersionError =
|
||||||
|
(error.statusCode === 409 && error.details?.duplicateVersion) ||
|
||||||
|
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
|
||||||
|
|
||||||
|
if (isDuplicateVersionError) {
|
||||||
|
let versionIndex: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (error.details?.versionIndex !== undefined) {
|
||||||
|
versionIndex = error.details.versionIndex;
|
||||||
|
} else if (error.response?.data?.details?.versionIndex !== undefined) {
|
||||||
|
versionIndex = error.response.data.details.versionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionIndex === undefined || versionIndex < 0) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_agents_update_error'),
|
||||||
|
status: 'error',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
|
||||||
|
status: 'error',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: `${localize('com_agents_update_error')}${
|
message: `${localize('com_agents_update_error')}${
|
||||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { EModelEndpoint } from 'librechat-data-provider';
|
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||||
import type { ActionsEndpoint } from '~/common';
|
import type { ActionsEndpoint } from '~/common';
|
||||||
import type { Action, TConfig, TEndpointsConfig } from 'librechat-data-provider';
|
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||||
import { useGetActionsQuery, useGetEndpointsQuery } from '~/data-provider';
|
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useChatContext } from '~/Providers';
|
||||||
import ActionsPanel from './ActionsPanel';
|
import ActionsPanel from './ActionsPanel';
|
||||||
import AgentPanel from './AgentPanel';
|
import AgentPanel from './AgentPanel';
|
||||||
|
import VersionPanel from './Version/VersionPanel';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
|
||||||
export default function AgentPanelSwitch() {
|
export default function AgentPanelSwitch() {
|
||||||
@@ -15,11 +16,19 @@ export default function AgentPanelSwitch() {
|
|||||||
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
||||||
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
||||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||||
|
const createMutation = useCreateAgentMutation();
|
||||||
|
|
||||||
const agentsConfig = useMemo(
|
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||||
() => endpointsConfig?.[EModelEndpoint.agents] ?? ({} as TConfig | null),
|
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||||
[endpointsConfig],
|
if (!config) return null;
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
...(config as TConfig),
|
||||||
|
capabilities: Array.isArray(config.capabilities)
|
||||||
|
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||||
|
: ([] as AgentCapabilities[]),
|
||||||
|
} as TAgentsEndpoint;
|
||||||
|
}, [endpointsConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const agent_id = conversation?.agent_id ?? '';
|
const agent_id = conversation?.agent_id ?? '';
|
||||||
@@ -41,12 +50,23 @@ export default function AgentPanelSwitch() {
|
|||||||
setActivePanel,
|
setActivePanel,
|
||||||
setCurrentAgentId,
|
setCurrentAgentId,
|
||||||
agent_id: currentAgentId,
|
agent_id: currentAgentId,
|
||||||
|
createMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activePanel === Panel.actions) {
|
if (activePanel === Panel.actions) {
|
||||||
return <ActionsPanel {...commonProps} />;
|
return <ActionsPanel {...commonProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activePanel === Panel.version) {
|
||||||
|
return (
|
||||||
|
<VersionPanel
|
||||||
|
setActivePanel={setActivePanel}
|
||||||
|
agentsConfig={agentsConfig}
|
||||||
|
selectedAgentId={currentAgentId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { History } from 'lucide-react';
|
||||||
|
import { Panel } from '~/common';
|
||||||
|
import { Button } from '~/components/ui';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface VersionButtonProps {
|
||||||
|
setActivePanel: (panel: Panel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'sm'}
|
||||||
|
variant={'outline'}
|
||||||
|
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||||
|
onClick={() => setActivePanel(Panel.version)}
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
|
||||||
|
{localize('com_ui_agent_version')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionButton;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Spinner } from '~/components/svg';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import VersionItem from './VersionItem';
|
||||||
|
import { VersionContext } from './VersionPanel';
|
||||||
|
|
||||||
|
type VersionContentProps = {
|
||||||
|
selectedAgentId: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
versionContext: VersionContext;
|
||||||
|
onRestore: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VersionContent({
|
||||||
|
selectedAgentId,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
versionContext,
|
||||||
|
onRestore,
|
||||||
|
}: VersionContentProps) {
|
||||||
|
const { versions, versionIds } = versionContext;
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
if (!selectedAgentId) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-text-secondary">
|
||||||
|
{localize('com_ui_agent_version_no_agent')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-red-500">{localize('com_ui_agent_version_error')}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionIds.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{versionIds.map(({ id, version, isActive }) => (
|
||||||
|
<VersionItem
|
||||||
|
key={id}
|
||||||
|
version={version}
|
||||||
|
index={id}
|
||||||
|
isActive={isActive}
|
||||||
|
versionsLength={versions.length}
|
||||||
|
onRestore={onRestore}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-text-secondary">
|
||||||
|
{localize('com_ui_agent_version_empty')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { VersionRecord } from './VersionPanel';
|
||||||
|
|
||||||
|
type VersionItemProps = {
|
||||||
|
version: VersionRecord;
|
||||||
|
index: number;
|
||||||
|
isActive: boolean;
|
||||||
|
versionsLength: number;
|
||||||
|
onRestore: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VersionItem({
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
isActive,
|
||||||
|
versionsLength,
|
||||||
|
onRestore,
|
||||||
|
}: VersionItemProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const getVersionTimestamp = (version: VersionRecord): string => {
|
||||||
|
const timestamp = version.updatedAt || version.createdAt;
|
||||||
|
|
||||||
|
if (timestamp) {
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (isNaN(date.getTime()) || date.toString() === 'Invalid Date') {
|
||||||
|
return localize('com_ui_agent_version_unknown_date');
|
||||||
|
}
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch (error) {
|
||||||
|
return localize('com_ui_agent_version_unknown_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localize('com_ui_agent_version_no_date');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border-light p-3">
|
||||||
|
<div className="flex items-center justify-between font-medium">
|
||||||
|
<span>
|
||||||
|
{localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded-full border border-green-600 bg-green-600/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-500 dark:bg-green-500/30 dark:text-green-300">
|
||||||
|
{localize('com_ui_agent_version_active')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary">{getVersionTimestamp(version)}</div>
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(localize('com_ui_agent_version_restore_confirm'))) {
|
||||||
|
onRestore(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={localize('com_ui_agent_version_restore')}
|
||||||
|
>
|
||||||
|
{localize('com_ui_agent_version_restore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import type { AgentPanelProps } from '~/common';
|
||||||
|
import { Panel } from '~/common';
|
||||||
|
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||||
|
import { useLocalize, useToast } from '~/hooks';
|
||||||
|
import VersionContent from './VersionContent';
|
||||||
|
import { isActiveVersion } from './isActiveVersion';
|
||||||
|
|
||||||
|
export type VersionRecord = Record<string, any>;
|
||||||
|
|
||||||
|
export type AgentState = {
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
instructions: string | null;
|
||||||
|
artifacts?: string | null;
|
||||||
|
capabilities?: string[];
|
||||||
|
tools?: string[];
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type VersionWithId = {
|
||||||
|
id: number;
|
||||||
|
originalIndex: number;
|
||||||
|
version: VersionRecord;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VersionContext = {
|
||||||
|
versions: VersionRecord[];
|
||||||
|
versionIds: VersionWithId[];
|
||||||
|
currentAgent: AgentState;
|
||||||
|
selectedAgentId: string;
|
||||||
|
activeVersion: VersionRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentWithVersions extends Agent {
|
||||||
|
capabilities?: string[];
|
||||||
|
versions?: Array<VersionRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersionPanelProps = {
|
||||||
|
agentsConfig: TAgentsEndpoint | null;
|
||||||
|
setActivePanel: AgentPanelProps['setActivePanel'];
|
||||||
|
selectedAgentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const {
|
||||||
|
data: agent,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useGetAgentByIdQuery(selectedAgentId, {
|
||||||
|
enabled: !!selectedAgentId && selectedAgentId !== '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const revertAgentVersion = useRevertAgentVersionMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_agent_version_restore_success'),
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_agent_version_restore_error'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentWithVersions = agent as AgentWithVersions;
|
||||||
|
|
||||||
|
const currentAgent = useMemo(() => {
|
||||||
|
if (!agentWithVersions) return null;
|
||||||
|
return {
|
||||||
|
name: agentWithVersions.name,
|
||||||
|
description: agentWithVersions.description,
|
||||||
|
instructions: agentWithVersions.instructions,
|
||||||
|
artifacts: agentWithVersions.artifacts,
|
||||||
|
capabilities: agentWithVersions.capabilities,
|
||||||
|
tools: agentWithVersions.tools,
|
||||||
|
};
|
||||||
|
}, [agentWithVersions]);
|
||||||
|
|
||||||
|
const versions = useMemo(() => {
|
||||||
|
const versionsCopy = [...(agentWithVersions?.versions || [])];
|
||||||
|
return versionsCopy.sort((a, b) => {
|
||||||
|
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||||
|
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
}, [agentWithVersions?.versions]);
|
||||||
|
|
||||||
|
const activeVersion = useMemo(() => {
|
||||||
|
return versions.length > 0
|
||||||
|
? versions.find((v) => isActiveVersion(v, currentAgent, versions)) || null
|
||||||
|
: null;
|
||||||
|
}, [versions, currentAgent]);
|
||||||
|
|
||||||
|
const versionIds = useMemo(() => {
|
||||||
|
if (versions.length === 0) return [];
|
||||||
|
|
||||||
|
const matchingVersions = versions.filter((v) => isActiveVersion(v, currentAgent, versions));
|
||||||
|
|
||||||
|
const activeVersionId =
|
||||||
|
matchingVersions.length > 0 ? versions.findIndex((v) => v === matchingVersions[0]) : -1;
|
||||||
|
|
||||||
|
return versions.map((version, displayIndex) => {
|
||||||
|
const originalIndex =
|
||||||
|
agentWithVersions?.versions?.findIndex(
|
||||||
|
(v) =>
|
||||||
|
v.updatedAt === version.updatedAt &&
|
||||||
|
v.createdAt === version.createdAt &&
|
||||||
|
v.name === version.name,
|
||||||
|
) ?? displayIndex;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: displayIndex,
|
||||||
|
originalIndex,
|
||||||
|
version,
|
||||||
|
isActive: displayIndex === activeVersionId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [versions, currentAgent, agentWithVersions?.versions]);
|
||||||
|
|
||||||
|
const versionContext: VersionContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
versions,
|
||||||
|
versionIds,
|
||||||
|
currentAgent,
|
||||||
|
selectedAgentId,
|
||||||
|
activeVersion,
|
||||||
|
}),
|
||||||
|
[versions, versionIds, currentAgent, selectedAgentId, activeVersion],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestore = useCallback(
|
||||||
|
(displayIndex: number) => {
|
||||||
|
const versionWithId = versionIds.find((v) => v.id === displayIndex);
|
||||||
|
|
||||||
|
if (versionWithId) {
|
||||||
|
const originalIndex = versionWithId.originalIndex;
|
||||||
|
|
||||||
|
revertAgentVersion.mutate({
|
||||||
|
agent_id: selectedAgentId,
|
||||||
|
version_index: originalIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[revertAgentVersion, selectedAgentId, versionIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
|
||||||
|
<div className="version-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||||
|
<div className="absolute left-0 top-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral relative"
|
||||||
|
onClick={() => {
|
||||||
|
setActivePanel(Panel.builder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="version-panel-content flex w-full items-center justify-center gap-2">
|
||||||
|
<ChevronLeft />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 mt-2 text-xl font-medium">
|
||||||
|
{localize('com_ui_agent_version_history')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 px-2">
|
||||||
|
<VersionContent
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
versionContext={versionContext}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import VersionContent from '../VersionContent';
|
||||||
|
import { VersionContext } from '../VersionPanel';
|
||||||
|
|
||||||
|
const mockRestore = 'Restore';
|
||||||
|
|
||||||
|
jest.mock('../VersionItem', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(({ version, isActive, onRestore, index }) => (
|
||||||
|
<div data-testid="version-item">
|
||||||
|
<div>{version.name}</div>
|
||||||
|
{!isActive && (
|
||||||
|
<button data-testid={`restore-button-${index}`} onClick={() => onRestore(index)}>
|
||||||
|
{mockRestore}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: jest.fn().mockImplementation(() => (key) => {
|
||||||
|
const translations = {
|
||||||
|
com_ui_agent_version_no_agent: 'No agent selected',
|
||||||
|
com_ui_agent_version_error: 'Error loading versions',
|
||||||
|
com_ui_agent_version_empty: 'No versions available',
|
||||||
|
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||||
|
com_ui_agent_version_restore: 'Restore',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/svg', () => ({
|
||||||
|
Spinner: () => <div data-testid="spinner" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockVersionItem = jest.requireMock('../VersionItem').default;
|
||||||
|
|
||||||
|
describe('VersionContent', () => {
|
||||||
|
const mockVersionIds = [
|
||||||
|
{ id: 0, version: { name: 'First' }, isActive: true, originalIndex: 2 },
|
||||||
|
{ id: 1, version: { name: 'Second' }, isActive: false, originalIndex: 1 },
|
||||||
|
{ id: 2, version: { name: 'Third' }, isActive: false, originalIndex: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockContext: VersionContext = {
|
||||||
|
versions: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }],
|
||||||
|
versionIds: mockVersionIds,
|
||||||
|
currentAgent: { name: 'Test Agent', description: null, instructions: null },
|
||||||
|
selectedAgentId: 'agent-123',
|
||||||
|
activeVersion: { name: 'First' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedAgentId: 'agent-123',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
versionContext: mockContext,
|
||||||
|
onRestore: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
window.confirm = jest.fn(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders different UI states correctly', () => {
|
||||||
|
const renderTest = (props) => {
|
||||||
|
const result = render(<VersionContent {...defaultProps} {...props} />);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId, unmount: unmount1 } = renderTest({ isLoading: true });
|
||||||
|
expect(getByTestId('spinner')).toBeInTheDocument();
|
||||||
|
unmount1();
|
||||||
|
|
||||||
|
const { getByText: getText1, unmount: unmount2 } = renderTest({
|
||||||
|
error: new Error('Test error'),
|
||||||
|
});
|
||||||
|
expect(getText1('Error loading versions')).toBeInTheDocument();
|
||||||
|
unmount2();
|
||||||
|
|
||||||
|
const { getByText: getText2, unmount: unmount3 } = renderTest({ selectedAgentId: '' });
|
||||||
|
expect(getText2('No agent selected')).toBeInTheDocument();
|
||||||
|
unmount3();
|
||||||
|
|
||||||
|
const emptyContext = { ...mockContext, versions: [], versionIds: [] };
|
||||||
|
const { getByText: getText3, unmount: unmount4 } = renderTest({ versionContext: emptyContext });
|
||||||
|
expect(getText3('No versions available')).toBeInTheDocument();
|
||||||
|
unmount4();
|
||||||
|
|
||||||
|
mockVersionItem.mockClear();
|
||||||
|
|
||||||
|
const { getAllByTestId } = renderTest({});
|
||||||
|
expect(getAllByTestId('version-item')).toHaveLength(3);
|
||||||
|
expect(mockVersionItem).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore functionality works correctly', () => {
|
||||||
|
const onRestoreMock = jest.fn();
|
||||||
|
const { getByTestId, queryByTestId } = render(
|
||||||
|
<VersionContent {...defaultProps} onRestore={onRestoreMock} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('restore-button-1'));
|
||||||
|
expect(onRestoreMock).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
expect(queryByTestId('restore-button-0')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('restore-button-1')).toBeInTheDocument();
|
||||||
|
expect(queryByTestId('restore-button-2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles edge cases in data', () => {
|
||||||
|
const { getAllByTestId, getByText, queryByTestId, queryByText, rerender } = render(
|
||||||
|
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versions: [] }} />,
|
||||||
|
);
|
||||||
|
expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versionIds: [] }} />,
|
||||||
|
);
|
||||||
|
expect(getByText('No versions available')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<VersionContent
|
||||||
|
{...defaultProps}
|
||||||
|
selectedAgentId=""
|
||||||
|
isLoading={true}
|
||||||
|
error={new Error('Test')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('No agent selected')).toBeInTheDocument();
|
||||||
|
expect(queryByTestId('spinner')).not.toBeInTheDocument();
|
||||||
|
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<VersionContent {...defaultProps} isLoading={true} error={new Error('Test')} />);
|
||||||
|
expect(queryByTestId('spinner')).toBeInTheDocument();
|
||||||
|
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import VersionItem from '../VersionItem';
|
||||||
|
import { VersionRecord } from '../VersionPanel';
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: jest.fn().mockImplementation(() => (key, params) => {
|
||||||
|
const translations = {
|
||||||
|
com_ui_agent_version_title: params?.versionNumber
|
||||||
|
? `Version ${params.versionNumber}`
|
||||||
|
: 'Version',
|
||||||
|
com_ui_agent_version_active: 'Active Version',
|
||||||
|
com_ui_agent_version_restore: 'Restore',
|
||||||
|
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||||
|
com_ui_agent_version_unknown_date: 'Unknown date',
|
||||||
|
com_ui_agent_version_no_date: 'No date',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('VersionItem', () => {
|
||||||
|
const mockVersion: VersionRecord = {
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Test Description',
|
||||||
|
instructions: 'Test Instructions',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
version: mockVersion,
|
||||||
|
index: 1,
|
||||||
|
isActive: false,
|
||||||
|
versionsLength: 3,
|
||||||
|
onRestore: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
window.confirm = jest.fn().mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders version number and timestamp', () => {
|
||||||
|
render(<VersionItem {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Version 2')).toBeInTheDocument();
|
||||||
|
const date = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||||
|
expect(screen.getByText(date)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active version badge and no restore button when active', () => {
|
||||||
|
render(<VersionItem {...defaultProps} isActive={true} />);
|
||||||
|
expect(screen.getByText('Active Version')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Restore')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore button and no active badge when not active', () => {
|
||||||
|
render(<VersionItem {...defaultProps} isActive={false} />);
|
||||||
|
expect(screen.queryByText('Active Version')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Restore')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore confirmation flow - confirmed', () => {
|
||||||
|
render(<VersionItem {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Restore'));
|
||||||
|
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to restore this version?');
|
||||||
|
expect(defaultProps.onRestore).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore confirmation flow - canceled', () => {
|
||||||
|
window.confirm = jest.fn().mockImplementation(() => false);
|
||||||
|
render(<VersionItem {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Restore'));
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
expect(defaultProps.onRestore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid timestamp', () => {
|
||||||
|
render(
|
||||||
|
<VersionItem {...defaultProps} version={{ ...mockVersion, updatedAt: 'invalid-date' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Unknown date')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing timestamps', () => {
|
||||||
|
render(
|
||||||
|
<VersionItem
|
||||||
|
{...defaultProps}
|
||||||
|
version={{ ...mockVersion, updatedAt: undefined, createdAt: undefined }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers updatedAt over createdAt when both exist', () => {
|
||||||
|
const versionWithBothDates = {
|
||||||
|
...mockVersion,
|
||||||
|
updatedAt: '2023-01-02T00:00:00Z',
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
render(<VersionItem {...defaultProps} version={versionWithBothDates} />);
|
||||||
|
const updatedDate = new Date('2023-01-02T00:00:00Z').toLocaleString();
|
||||||
|
expect(screen.getByText(updatedDate)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to createdAt when updatedAt is missing', () => {
|
||||||
|
render(
|
||||||
|
<VersionItem
|
||||||
|
{...defaultProps}
|
||||||
|
version={{
|
||||||
|
...mockVersion,
|
||||||
|
updatedAt: undefined,
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||||
|
expect(screen.getByText(createdDate)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty version object', () => {
|
||||||
|
render(<VersionItem {...defaultProps} version={{}} />);
|
||||||
|
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { Panel } from '~/common/types';
|
||||||
|
import VersionContent from '../VersionContent';
|
||||||
|
import VersionPanel from '../VersionPanel';
|
||||||
|
|
||||||
|
const mockAgentData = {
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Test Description',
|
||||||
|
instructions: 'Test Instructions',
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
capabilities: ['capability1', 'capability2'],
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
name: 'Version 1',
|
||||||
|
description: 'Description 1',
|
||||||
|
instructions: 'Instructions 1',
|
||||||
|
tools: ['tool1'],
|
||||||
|
capabilities: ['capability1'],
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Version 2',
|
||||||
|
description: 'Description 2',
|
||||||
|
instructions: 'Instructions 2',
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
capabilities: ['capability1', 'capability2'],
|
||||||
|
createdAt: '2023-01-02T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useGetAgentByIdQuery: jest.fn(() => ({
|
||||||
|
data: mockAgentData,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})),
|
||||||
|
useRevertAgentVersionMutation: jest.fn(() => ({
|
||||||
|
mutate: jest.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../VersionContent', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="version-content" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: jest.fn().mockImplementation(() => (key) => key),
|
||||||
|
useToast: jest.fn(() => ({ showToast: jest.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('VersionPanel', () => {
|
||||||
|
const mockSetActivePanel = jest.fn();
|
||||||
|
const defaultProps = {
|
||||||
|
agentsConfig: null,
|
||||||
|
setActivePanel: mockSetActivePanel,
|
||||||
|
selectedAgentId: 'agent-123',
|
||||||
|
};
|
||||||
|
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||||
|
data: mockAgentData,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders panel UI and handles navigation', () => {
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('version-content')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(mockSetActivePanel).toHaveBeenCalledWith(Panel.builder);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VersionContent receives correct props', () => {
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
selectedAgentId: 'agent-123',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
versionContext: expect.objectContaining({
|
||||||
|
currentAgent: expect.any(Object),
|
||||||
|
versions: expect.any(Array),
|
||||||
|
versionIds: expect.any(Array),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles data state variations', () => {
|
||||||
|
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ selectedAgentId: '' }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
versionContext: expect.objectContaining({
|
||||||
|
versions: [],
|
||||||
|
versionIds: [],
|
||||||
|
currentAgent: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||||
|
data: { ...mockAgentData, versions: undefined },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
versionContext: expect.objectContaining({ versions: [] }),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ isLoading: true }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const testError = new Error('Test error');
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: testError,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ error: testError }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memoizes agent data correctly', () => {
|
||||||
|
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||||
|
data: mockAgentData,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<VersionPanel {...defaultProps} />);
|
||||||
|
expect(VersionContent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
versionContext: expect.objectContaining({
|
||||||
|
currentAgent: expect.objectContaining({
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Test Description',
|
||||||
|
instructions: 'Test Instructions',
|
||||||
|
}),
|
||||||
|
versions: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Version 2' }),
|
||||||
|
expect.objectContaining({ name: 'Version 1' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { isActiveVersion } from '../isActiveVersion';
|
||||||
|
import type { AgentState, VersionRecord } from '../VersionPanel';
|
||||||
|
|
||||||
|
describe('isActiveVersion', () => {
|
||||||
|
const createVersion = (overrides = {}): VersionRecord => ({
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Test Description',
|
||||||
|
instructions: 'Test Instructions',
|
||||||
|
artifacts: 'default',
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
capabilities: ['capability1', 'capability2'],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAgentState = (overrides = {}): AgentState => ({
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Test Description',
|
||||||
|
instructions: 'Test Instructions',
|
||||||
|
artifacts: 'default',
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
capabilities: ['capability1', 'capability2'],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true for the first version in versions array when currentAgent is null', () => {
|
||||||
|
const versions = [
|
||||||
|
createVersion({ name: 'First Version' }),
|
||||||
|
createVersion({ name: 'Second Version' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(isActiveVersion(versions[0], null, versions)).toBe(true);
|
||||||
|
expect(isActiveVersion(versions[1], null, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when all fields match exactly', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState();
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when names do not match', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState({ name: 'Different Name' });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when descriptions do not match', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState({ description: 'Different Description' });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when instructions do not match', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState({ instructions: 'Different Instructions' });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when artifacts do not match', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState({ artifacts: 'different_artifacts' });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches tools regardless of order', () => {
|
||||||
|
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||||
|
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when tools arrays have different lengths', () => {
|
||||||
|
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||||
|
const currentAgent = createAgentState({ tools: ['tool1', 'tool2', 'tool3'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when tools do not match', () => {
|
||||||
|
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||||
|
const currentAgent = createAgentState({ tools: ['tool1', 'different'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches capabilities regardless of order', () => {
|
||||||
|
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||||
|
const currentAgent = createAgentState({ capabilities: ['capability2', 'capability1'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when capabilities arrays have different lengths', () => {
|
||||||
|
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||||
|
const currentAgent = createAgentState({
|
||||||
|
capabilities: ['capability1', 'capability2', 'capability3'],
|
||||||
|
});
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when capabilities do not match', () => {
|
||||||
|
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||||
|
const currentAgent = createAgentState({ capabilities: ['capability1', 'different'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('handles missing tools arrays', () => {
|
||||||
|
const version = createVersion({ tools: undefined });
|
||||||
|
const currentAgent = createAgentState({ tools: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when version has tools but agent does not', () => {
|
||||||
|
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||||
|
const currentAgent = createAgentState({ tools: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when agent has tools but version does not', () => {
|
||||||
|
const version = createVersion({ tools: undefined });
|
||||||
|
const currentAgent = createAgentState({ tools: ['tool1', 'tool2'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing capabilities arrays', () => {
|
||||||
|
const version = createVersion({ capabilities: undefined });
|
||||||
|
const currentAgent = createAgentState({ capabilities: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when version has capabilities but agent does not', () => {
|
||||||
|
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||||
|
const currentAgent = createAgentState({ capabilities: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when agent has capabilities but version does not', () => {
|
||||||
|
const version = createVersion({ capabilities: undefined });
|
||||||
|
const currentAgent = createAgentState({ capabilities: ['capability1', 'capability2'] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null values in fields', () => {
|
||||||
|
const version = createVersion({ name: null });
|
||||||
|
const currentAgent = createAgentState({ name: null });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty versions array', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState();
|
||||||
|
const versions = [];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty arrays for tools', () => {
|
||||||
|
const version = createVersion({ tools: [] });
|
||||||
|
const currentAgent = createAgentState({ tools: [] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty arrays for capabilities', () => {
|
||||||
|
const version = createVersion({ capabilities: [] });
|
||||||
|
const currentAgent = createAgentState({ capabilities: [] });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing artifacts field', () => {
|
||||||
|
const version = createVersion({ artifacts: undefined });
|
||||||
|
const currentAgent = createAgentState({ artifacts: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when version has artifacts but agent does not', () => {
|
||||||
|
const version = createVersion();
|
||||||
|
const currentAgent = createAgentState({ artifacts: undefined });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles when agent has artifacts but version does not', () => {
|
||||||
|
const version = createVersion({ artifacts: undefined });
|
||||||
|
const currentAgent = createAgentState();
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty string for artifacts', () => {
|
||||||
|
const version = createVersion({ artifacts: '' });
|
||||||
|
const currentAgent = createAgentState({ artifacts: '' });
|
||||||
|
const versions = [version];
|
||||||
|
|
||||||
|
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AgentState, VersionRecord } from './VersionPanel';
|
||||||
|
|
||||||
|
export const isActiveVersion = (
|
||||||
|
version: VersionRecord,
|
||||||
|
currentAgent: AgentState,
|
||||||
|
versions: VersionRecord[],
|
||||||
|
): boolean => {
|
||||||
|
if (!versions || versions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentAgent) {
|
||||||
|
const versionIndex = versions.findIndex(
|
||||||
|
(v) =>
|
||||||
|
v.name === version.name &&
|
||||||
|
v.instructions === version.instructions &&
|
||||||
|
v.artifacts === version.artifacts,
|
||||||
|
);
|
||||||
|
return versionIndex === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesName = version.name === currentAgent.name;
|
||||||
|
const matchesDescription = version.description === currentAgent.description;
|
||||||
|
const matchesInstructions = version.instructions === currentAgent.instructions;
|
||||||
|
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
|
||||||
|
|
||||||
|
const toolsMatch = () => {
|
||||||
|
if (!version.tools && !currentAgent.tools) return true;
|
||||||
|
if (!version.tools || !currentAgent.tools) return false;
|
||||||
|
if (version.tools.length !== currentAgent.tools.length) return false;
|
||||||
|
|
||||||
|
const sortedVersionTools = [...version.tools].sort();
|
||||||
|
const sortedCurrentTools = [...currentAgent.tools].sort();
|
||||||
|
|
||||||
|
return sortedVersionTools.every((tool, i) => tool === sortedCurrentTools[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const capabilitiesMatch = () => {
|
||||||
|
if (!version.capabilities && !currentAgent.capabilities) return true;
|
||||||
|
if (!version.capabilities || !currentAgent.capabilities) return false;
|
||||||
|
if (version.capabilities.length !== currentAgent.capabilities.length) return false;
|
||||||
|
|
||||||
|
const sortedVersionCapabilities = [...version.capabilities].sort();
|
||||||
|
const sortedCurrentCapabilities = [...currentAgent.capabilities].sort();
|
||||||
|
|
||||||
|
return sortedVersionCapabilities.every(
|
||||||
|
(capability, i) => capability === sortedCurrentCapabilities[i],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesName &&
|
||||||
|
matchesDescription &&
|
||||||
|
matchesInstructions &&
|
||||||
|
matchesArtifacts &&
|
||||||
|
toolsMatch() &&
|
||||||
|
capabilitiesMatch()
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import AgentFooter from '../AgentFooter';
|
||||||
|
import { Panel } from '~/common';
|
||||||
|
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||||
|
import { SystemRoles } from 'librechat-data-provider';
|
||||||
|
import * as reactHookForm from 'react-hook-form';
|
||||||
|
import * as hooks from '~/hooks';
|
||||||
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
jest.mock('react-hook-form', () => ({
|
||||||
|
useFormContext: () => ({
|
||||||
|
control: {},
|
||||||
|
}),
|
||||||
|
useWatch: () => {
|
||||||
|
return {
|
||||||
|
agent: {
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: 'user-123',
|
||||||
|
projectIds: ['project-1'],
|
||||||
|
isCollaborative: false,
|
||||||
|
},
|
||||||
|
id: 'agent-123',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: 'user-123',
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
avatar: '',
|
||||||
|
role: 'USER',
|
||||||
|
provider: 'local',
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
} as TUser;
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: () => (key) => {
|
||||||
|
const translations = {
|
||||||
|
com_ui_save: 'Save',
|
||||||
|
com_ui_create: 'Create',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
},
|
||||||
|
useAuthContext: () => ({
|
||||||
|
user: mockUser,
|
||||||
|
token: 'mock-token',
|
||||||
|
isAuthenticated: true,
|
||||||
|
error: undefined,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
setError: jest.fn(),
|
||||||
|
roles: {},
|
||||||
|
}),
|
||||||
|
useHasAccess: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createBaseMutation = <T = Agent, P = any>(
|
||||||
|
isLoading = false,
|
||||||
|
): UseMutationResult<T, Error, P> => {
|
||||||
|
if (isLoading) {
|
||||||
|
return {
|
||||||
|
mutate: jest.fn(),
|
||||||
|
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
isIdle: false as const,
|
||||||
|
status: 'loading' as const,
|
||||||
|
error: null,
|
||||||
|
data: undefined,
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
reset: jest.fn(),
|
||||||
|
context: undefined,
|
||||||
|
variables: undefined,
|
||||||
|
isPaused: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
mutate: jest.fn(),
|
||||||
|
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
isIdle: true as const,
|
||||||
|
status: 'idle' as const,
|
||||||
|
error: null,
|
||||||
|
data: undefined,
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
reset: jest.fn(),
|
||||||
|
context: undefined,
|
||||||
|
variables: undefined,
|
||||||
|
isPaused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useUpdateAgentMutation: () => createBaseMutation<Agent, any>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Advanced/AdvancedButton', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="advanced-button" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Version/VersionButton', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="version-button" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AdminSettings', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="admin-settings" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../DeleteButton', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../ShareAgent', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="share-agent" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../DuplicateAgent', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => <div data-testid="duplicate-agent" />),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components', () => ({
|
||||||
|
Spinner: () => <div data-testid="spinner" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AgentFooter', () => {
|
||||||
|
const mockUsers = {
|
||||||
|
regular: mockUser,
|
||||||
|
admin: {
|
||||||
|
...mockUser,
|
||||||
|
id: 'admin-123',
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: SystemRoles.ADMIN,
|
||||||
|
} as TUser,
|
||||||
|
different: {
|
||||||
|
...mockUser,
|
||||||
|
id: 'different-user',
|
||||||
|
username: 'different',
|
||||||
|
email: 'different@example.com',
|
||||||
|
name: 'Different User',
|
||||||
|
} as TUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAuthContext = (user: TUser) => ({
|
||||||
|
user,
|
||||||
|
token: 'mock-token',
|
||||||
|
isAuthenticated: true,
|
||||||
|
error: undefined,
|
||||||
|
login: jest.fn(),
|
||||||
|
logout: jest.fn(),
|
||||||
|
setError: jest.fn(),
|
||||||
|
roles: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSetActivePanel = jest.fn();
|
||||||
|
const mockSetCurrentAgentId = jest.fn();
|
||||||
|
const mockCreateMutation = createBaseMutation<Agent, AgentCreateParams>();
|
||||||
|
const mockUpdateMutation = createBaseMutation<Agent, any>();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
activePanel: Panel.builder,
|
||||||
|
createMutation: mockCreateMutation,
|
||||||
|
updateMutation: mockUpdateMutation,
|
||||||
|
setActivePanel: mockSetActivePanel,
|
||||||
|
setCurrentAgentId: mockSetCurrentAgentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Main Functionality', () => {
|
||||||
|
test('renders with standard components based on default state', () => {
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('advanced-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles loading states for createMutation', () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles loading states for updateMutation', () => {
|
||||||
|
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
|
||||||
|
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Conditional Rendering', () => {
|
||||||
|
test('adjusts UI based on activePanel state', () => {
|
||||||
|
render(<AgentFooter {...defaultProps} activePanel={Panel.advanced} />);
|
||||||
|
expect(screen.queryByTestId('advanced-button')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts UI based on agent ID existence', () => {
|
||||||
|
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||||
|
agent: { name: 'Test Agent', author: 'user-123' },
|
||||||
|
id: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts UI based on user role', () => {
|
||||||
|
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adjusts UI based on permissions', () => {
|
||||||
|
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
test('handles null agent data', () => {
|
||||||
|
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||||
|
agent: null,
|
||||||
|
id: 'agent-123',
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<AgentFooter {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,11 @@ export const useCreateAgentMutation = (
|
|||||||
*/
|
*/
|
||||||
export const useUpdateAgentMutation = (
|
export const useUpdateAgentMutation = (
|
||||||
options?: t.UpdateAgentMutationOptions,
|
options?: t.UpdateAgentMutationOptions,
|
||||||
): UseMutationResult<t.Agent, Error, { agent_id: string; data: t.AgentUpdateParams }> => {
|
): UseMutationResult<
|
||||||
|
t.Agent,
|
||||||
|
t.DuplicateVersionError,
|
||||||
|
{ agent_id: string; data: t.AgentUpdateParams }
|
||||||
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(
|
return useMutation(
|
||||||
({ agent_id, data }: { agent_id: string; data: t.AgentUpdateParams }) => {
|
({ agent_id, data }: { agent_id: string; data: t.AgentUpdateParams }) => {
|
||||||
@@ -54,7 +58,10 @@ export const useUpdateAgentMutation = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onMutate: (variables) => options?.onMutate?.(variables),
|
onMutate: (variables) => options?.onMutate?.(variables),
|
||||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
onError: (error, variables, context) => {
|
||||||
|
const typedError = error as t.DuplicateVersionError;
|
||||||
|
return options?.onError?.(typedError, variables, context);
|
||||||
|
},
|
||||||
onSuccess: (updatedAgent, variables, context) => {
|
onSuccess: (updatedAgent, variables, context) => {
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
||||||
QueryKeys.agents,
|
QueryKeys.agents,
|
||||||
@@ -170,7 +177,6 @@ export const useUploadAgentAvatarMutation = (
|
|||||||
unknown // context
|
unknown // context
|
||||||
> => {
|
> => {
|
||||||
return useMutation([MutationKeys.agentAvatarUpload], {
|
return useMutation([MutationKeys.agentAvatarUpload], {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) =>
|
mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) =>
|
||||||
dataService.uploadAgentAvatar(variables),
|
dataService.uploadAgentAvatar(variables),
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
@@ -300,3 +306,46 @@ export const useDeleteAgentAction = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for reverting an agent to a previous version
|
||||||
|
*/
|
||||||
|
export const useRevertAgentVersionMutation = (
|
||||||
|
options?: t.RevertAgentVersionOptions,
|
||||||
|
): UseMutationResult<t.Agent, Error, { agent_id: string; version_index: number }> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(
|
||||||
|
({ agent_id, version_index }: { agent_id: string; version_index: number }) => {
|
||||||
|
return dataService.revertAgentVersion({
|
||||||
|
agent_id,
|
||||||
|
version_index,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onMutate: (variables) => options?.onMutate?.(variables),
|
||||||
|
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||||
|
onSuccess: (revertedAgent, variables, context) => {
|
||||||
|
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
|
||||||
|
|
||||||
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
||||||
|
QueryKeys.agents,
|
||||||
|
defaultOrderQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listRes) {
|
||||||
|
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
||||||
|
...listRes,
|
||||||
|
data: listRes.data.map((agent) => {
|
||||||
|
if (agent.id === variables.agent_id) {
|
||||||
|
return revertedAgent;
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options?.onSuccess?.(revertedAgent, variables, context);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -483,6 +483,20 @@
|
|||||||
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
|
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
|
||||||
"com_ui_agent_shared_to_all": "something needs to go here. was empty",
|
"com_ui_agent_shared_to_all": "something needs to go here. was empty",
|
||||||
"com_ui_agent_var": "{{0}} agent",
|
"com_ui_agent_var": "{{0}} agent",
|
||||||
|
"com_ui_agent_version": "Version",
|
||||||
|
"com_ui_agent_version_history": "Version History",
|
||||||
|
"com_ui_agent_version_error": "Error fetching versions",
|
||||||
|
"com_ui_agent_version_empty": "No versions available",
|
||||||
|
"com_ui_agent_version_title": "Version {{versionNumber}}",
|
||||||
|
"com_ui_agent_version_restore": "Restore",
|
||||||
|
"com_ui_agent_version_restore_confirm": "Are you sure you want to restore this version?",
|
||||||
|
"com_ui_agent_version_restore_success": "Version restored successfully",
|
||||||
|
"com_ui_agent_version_restore_error": "Failed to restore version",
|
||||||
|
"com_ui_agent_version_no_agent": "No agent selected. Please select an agent to view version history.",
|
||||||
|
"com_ui_agent_version_unknown_date": "Unknown date",
|
||||||
|
"com_ui_agent_version_no_date": "Date not available",
|
||||||
|
"com_ui_agent_version_active": "Active Version",
|
||||||
|
"com_ui_agent_version_duplicate": "Duplicate version detected. This would create a version identical to Version {{versionIndex}}.",
|
||||||
"com_ui_agents": "Agents",
|
"com_ui_agents": "Agents",
|
||||||
"com_ui_agents_allow_create": "Allow creating Agents",
|
"com_ui_agents_allow_create": "Allow creating Agents",
|
||||||
"com_ui_agents_allow_share_global": "Allow sharing Agents to all users",
|
"com_ui_agents_allow_share_global": "Allow sharing Agents to all users",
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
|
|||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
||||||
|
|
||||||
export const files = () => '/api/files';
|
export const files = () => '/api/files';
|
||||||
|
|
||||||
export const images = () => `${files()}/images`;
|
export const images = () => `${files()}/images`;
|
||||||
|
|||||||
@@ -431,6 +431,14 @@ export const listAgents = (params: a.AgentListParams): Promise<a.AgentListRespon
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const revertAgentVersion = ({
|
||||||
|
agent_id,
|
||||||
|
version_index,
|
||||||
|
}: {
|
||||||
|
agent_id: string;
|
||||||
|
version_index: number;
|
||||||
|
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
|
||||||
|
|
||||||
/* Tools */
|
/* Tools */
|
||||||
|
|
||||||
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {
|
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export enum MutationKeys {
|
|||||||
updateAgentAction = 'updateAgentAction',
|
updateAgentAction = 'updateAgentAction',
|
||||||
deleteAction = 'deleteAction',
|
deleteAction = 'deleteAction',
|
||||||
deleteAgentAction = 'deleteAgentAction',
|
deleteAgentAction = 'deleteAgentAction',
|
||||||
|
revertAgentVersion = 'revertAgentVersion',
|
||||||
deleteUser = 'deleteUser',
|
deleteUser = 'deleteUser',
|
||||||
updateRole = 'updateRole',
|
updateRole = 'updateRole',
|
||||||
enableTwoFactor = 'enableTwoFactor',
|
enableTwoFactor = 'enableTwoFactor',
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export type Agent = {
|
|||||||
hide_sequential_outputs?: boolean;
|
hide_sequential_outputs?: boolean;
|
||||||
artifacts?: ArtifactModes;
|
artifacts?: ArtifactModes;
|
||||||
recursion_limit?: number;
|
recursion_limit?: number;
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAgentsMap = Record<string, Agent | undefined>;
|
export type TAgentsMap = Record<string, Agent | undefined>;
|
||||||
|
|||||||
@@ -129,7 +129,20 @@ export type UpdateAgentVariables = {
|
|||||||
data: AgentUpdateParams;
|
data: AgentUpdateParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateAgentMutationOptions = MutationOptions<Agent, UpdateAgentVariables>;
|
export type DuplicateVersionError = Error & {
|
||||||
|
statusCode?: number;
|
||||||
|
details?: {
|
||||||
|
duplicateVersion?: any;
|
||||||
|
versionIndex?: number
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAgentMutationOptions = MutationOptions<
|
||||||
|
Agent,
|
||||||
|
UpdateAgentVariables,
|
||||||
|
unknown,
|
||||||
|
DuplicateVersionError
|
||||||
|
>;
|
||||||
|
|
||||||
export type DuplicateAgentBody = {
|
export type DuplicateAgentBody = {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
@@ -159,6 +172,13 @@ export type DeleteAgentActionVariables = {
|
|||||||
|
|
||||||
export type DeleteAgentActionOptions = MutationOptions<void, DeleteAgentActionVariables>;
|
export type DeleteAgentActionOptions = MutationOptions<void, DeleteAgentActionVariables>;
|
||||||
|
|
||||||
|
export type RevertAgentVersionVariables = {
|
||||||
|
agent_id: string;
|
||||||
|
version_index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RevertAgentVersionOptions = MutationOptions<Agent, RevertAgentVersionVariables>;
|
||||||
|
|
||||||
export type DeleteConversationOptions = MutationOptions<
|
export type DeleteConversationOptions = MutationOptions<
|
||||||
types.TDeleteConversationResponse,
|
types.TDeleteConversationResponse,
|
||||||
types.TDeleteConversationRequest
|
types.TDeleteConversationRequest
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface IAgent extends Omit<Document, 'model'> {
|
|||||||
conversation_starters?: string[];
|
conversation_starters?: string[];
|
||||||
tool_resources?: unknown;
|
tool_resources?: unknown;
|
||||||
projectIds?: Types.ObjectId[];
|
projectIds?: Types.ObjectId[];
|
||||||
|
versions?: Omit<IAgent, 'versions'>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentSchema = new Schema<IAgent>(
|
const agentSchema = new Schema<IAgent>(
|
||||||
@@ -115,6 +116,10 @@ const agentSchema = new Schema<IAgent>(
|
|||||||
ref: 'Project',
|
ref: 'Project',
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
|
versions: {
|
||||||
|
type: [Schema.Types.Mixed],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user