Skip to content

Commit

Permalink
🤖 feat: Add Agent Duplication Functionality with Permission
Browse files Browse the repository at this point in the history
  • Loading branch information
danny-avila committed Dec 17, 2024
1 parent e391347 commit 5252fd3
Show file tree
Hide file tree
Showing 16 changed files with 277 additions and 35 deletions.
4 changes: 4 additions & 0 deletions api/models/schema/roleSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const roleSchema = new mongoose.Schema({
type: Boolean,
default: true,
},
[Permissions.DUPLICATE]: {
type: Boolean,
default: false,
},
},
[PermissionTypes.MULTI_CONVO]: {
[Permissions.USE]: {
Expand Down
85 changes: 84 additions & 1 deletion api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { FileContext, Constants, Tools, SystemRoles } = require('librechat-data-provider');
const {
FileContext,
Constants,
Tools,
SystemRoles,
actionDelimiter,
} = require('librechat-data-provider');
const {
getAgent,
createAgent,
Expand All @@ -10,6 +16,7 @@ const {
} = require('~/models/Agent');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { updateAction, getActions } = require('~/models/Action');
const { getProjectByName } = require('~/models/Project');
const { updateAgentProjects } = require('~/models/Agent');
const { deleteFileByFilter } = require('~/models/File');
Expand Down Expand Up @@ -173,6 +180,81 @@ const updateAgentHandler = async (req, res) => {
}
};

/**
* Duplicates an Agent based on the provided ID.
* @route POST /Agents/:id/duplicate
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - success response - application/json
*/
const duplicateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { id: userId } = req.user;

const agent = await getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}

const { name, description, instructions, tools, provider, model } = agent;
const newAgent = await createAgent({
id: `agent_${nanoid()}`,
author: userId,
name: `${name} (Copy)`,
description,
instructions,
tools,
provider,
model,
actions: [],
});

const originalActions = await getActions({ agent_id: id }, true);
let actionsData = [];

if (originalActions && originalActions.length > 0) {
const newActions = [];

for (const action of originalActions) {
const newActionId = nanoid();
const [domain] = action.action_id.split(actionDelimiter);

const fullActionId = `${domain}${actionDelimiter}${newActionId}`;

await updateAction(
{ action_id: newActionId },
{
metadata: action.metadata,
agent_id: newAgent.id,
user: userId,
},
);

newActions.push(fullActionId);
actionsData.push({
id: newActionId,
action_id: fullActionId,
metadata: action.metadata,
domain,
});
}

await updateAgent({ id: newAgent.id }, { actions: newActions });
}

const finalAgent = await getAgent({ id: newAgent.id });
return res.status(201).json({
agent: finalAgent,
actions: actionsData,
});
} catch (error) {
logger.error('[/Agents/:id/duplicate] Error duplicating Agent', error);
res.status(500).json({ error: error.message });
}
};

/**
* Deletes an Agent based on the provided ID.
* @route DELETE /Agents/:id
Expand Down Expand Up @@ -292,6 +374,7 @@ module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
updateAgent: updateAgentHandler,
duplicateAgent: duplicateAgentHandler,
deleteAgent: deleteAgentHandler,
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
Expand Down
10 changes: 10 additions & 0 deletions api/server/routes/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.CREATE,
]);

const checkAgentDuplicate = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.DUPLICATE]);

const checkGlobalAgentShare = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.USE, Permissions.CREATE],
Expand Down Expand Up @@ -62,6 +64,14 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
*/
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);

/**
* Duplicates an agent.
* @route POST /agents/:id/duplicate
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - Success response - application/json
*/
router.post('/:id/duplicate', checkAgentDuplicate, v1.duplicateAgent);

/**
* Deletes an agent.
* @route DELETE /agents/:id
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Nav/NavToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLocalize, useLocalStorage } from '~/hooks';
import { useLocalize } from '~/hooks';
import { TooltipAnchor } from '~/components/ui';
import { cn } from '~/utils';

Expand Down
6 changes: 5 additions & 1 deletion client/src/components/SidePanel/Agents/AdminSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ const AdminSettings = () => {
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
{
agentPerm: Permissions.DUPLICATE,
label: localize('com_ui_agents_allow_duplicate'),
},
];

const onSubmit = (data: FormValues) => {
Expand Down Expand Up @@ -142,7 +146,7 @@ const AdminSettings = () => {
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full gap-1 rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative mb-4 h-9 w-full gap-1 rounded-lg font-medium"
>
<ShieldEllipsis className="cursor-pointer" />
{localize('com_ui_admin_settings')}
Expand Down
11 changes: 10 additions & 1 deletion client/src/components/SidePanel/Agents/AgentConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import { useToastContext, useFileMapContext } from '~/Providers';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import DuplicateAgent from './DuplicateAgent';
import { processAgentOption } from '~/utils';
import AdminSettings from './AdminSettings';
import { Spinner } from '~/components/svg';
import DeleteButton from './DeleteButton';
import AgentAvatar from './AgentAvatar';
import { Spinner } from '~/components';
import FileSearch from './FileSearch';
import ShareAgent from './ShareAgent';
import AgentTool from './AgentTool';
Expand Down Expand Up @@ -68,6 +69,11 @@ export default function AgentConfig({
permission: Permissions.SHARED_GLOBAL,
});

const hasAccessToDuplicateAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.DUPLICATE,
});

const toolsEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.tools),
[agentsConfig],
Expand Down Expand Up @@ -421,6 +427,9 @@ export default function AgentConfig({
isCollaborative={agent?.isCollaborative}
/>
)}
{agent && agent.author === user?.id && hasAccessToDuplicateAgents && (
<DuplicateAgent agent_id={agent_id} />
)}
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
Expand Down
15 changes: 9 additions & 6 deletions client/src/components/SidePanel/Agents/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createProviderOption } from '~/utils';
import { useToastContext } from '~/Providers';
import AgentConfig from './AgentConfig';
import AgentSelect from './AgentSelect';
import { Button } from '~/components';
import ModelPanel from './ModelPanel';
import { Panel } from '~/common';

Expand Down Expand Up @@ -208,7 +209,7 @@ export default function AgentPanel({
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
aria-label="Agent configuration form"
>
<div className="flex w-full flex-wrap">
<div className="mt-2 flex w-full flex-wrap gap-2">
<Controller
name="agent"
control={control}
Expand All @@ -225,15 +226,17 @@ export default function AgentPanel({
/>
{/* Select Button */}
{agent_id && (
<button
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
type="button"
<Button
variant="submit"
disabled={!agent_id}
onClick={handleSelectAgent}
onClick={(e) => {
e.preventDefault();
handleSelectAgent();
}}
aria-label="Select agent"
>
{localize('com_ui_select')}
</button>
</Button>
)}
</div>
{!canEditAgent && (
Expand Down
52 changes: 30 additions & 22 deletions client/src/components/SidePanel/Agents/AgentPanelSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export default function AgentPanelSkeleton() {
<div className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden">
{/* Agent Select and Button */}
<div className="mt-1 flex w-full gap-2">
<Skeleton className="h-[40px] w-3/4 rounded" />
<Skeleton className="h-[40px] w-1/4 rounded" />
<Skeleton className="h-[40px] w-4/5 rounded-lg" />
<Skeleton className="h-[40px] w-1/5 rounded-lg" />
</div>

<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
Expand All @@ -17,52 +17,60 @@ export default function AgentPanelSkeleton() {
<Skeleton className="relative h-20 w-20 rounded-full" />
</div>
{/* Name */}
<Skeleton className="mb-2 h-5 w-1/5 rounded" />
<Skeleton className="mb-1 h-[40px] w-full rounded" />
<Skeleton className="h-3 w-1/4 rounded" />
<Skeleton className="mb-2 h-5 w-1/5 rounded-lg" />
<Skeleton className="mb-1 h-[40px] w-full rounded-lg" />
<Skeleton className="h-3 w-1/4 rounded-lg" />
</div>

{/* Description */}
<div className="mb-4">
<Skeleton className="mb-2 h-5 w-1/4 rounded" />
<Skeleton className="h-[40px] w-full rounded" />
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="h-[40px] w-full rounded-lg" />
</div>

{/* Instructions */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-1/4 rounded" />
<Skeleton className="h-[100px] w-full rounded" />
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="h-[100px] w-full rounded-lg" />
</div>

{/* Model and Provider */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-1/4 rounded" />
<Skeleton className="h-[40px] w-full rounded" />
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="h-[40px] w-full rounded-lg" />
</div>

{/* Capabilities */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-1/4 rounded" />
<Skeleton className="mb-2 h-[40px] w-full rounded" />
<Skeleton className="h-[40px] w-full rounded" />
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="mb-2 h-5 w-36 rounded-lg" />
<Skeleton className="mb-4 h-[35px] w-full rounded-lg" />
<Skeleton className="mb-2 h-5 w-24 rounded-lg" />
<Skeleton className="h-[35px] w-full rounded-lg" />
</div>

{/* Tools & Actions */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-1/4 rounded" />
<Skeleton className="mb-2 h-[40px] w-full rounded" />
<Skeleton className="mb-2 h-[40px] w-full rounded" />
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
<div className="flex space-x-2">
<Skeleton className="h-8 w-1/2 rounded" />
<Skeleton className="h-8 w-1/2 rounded" />
<Skeleton className="h-8 w-1/2 rounded-lg" />
<Skeleton className="h-8 w-1/2 rounded-lg" />
</div>
</div>

{/* Admin Settings */}
<div className="mb-6">
<Skeleton className="h-[35px] w-full rounded-lg" />
</div>

{/* Bottom Buttons */}
<div className="flex items-center justify-end gap-2">
<Skeleton className="h-[40px] w-[100px] rounded" />
<Skeleton className="h-[40px] w-[100px] rounded" />
<Skeleton className="h-[40px] w-[100px] rounded" />
<Skeleton className="h-[35px] w-16 rounded-lg" />
<Skeleton className="h-[35px] w-16 rounded-lg" />
<Skeleton className="h-[35px] w-16 rounded-lg" />
<Skeleton className="h-[35px] w-full rounded-lg" />
</div>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/SidePanel/Agents/AgentSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ export default function AgentSelect({
hasAgentValue ? 'text-gray-500' : '',
)}
className={cn(
'mt-1 rounded-md dark:border-gray-700 dark:bg-gray-850',
'z-50 flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer hover:border-green-500 focus:border-gray-400',
'rounded-md dark:border-gray-700 dark:bg-gray-850',
'max-w z-50 flex h-[40px] w-full flex-none items-center justify-center truncate px-4 hover:cursor-pointer hover:border-green-500 focus:border-gray-400',
)}
renderOption={() => (
<span className="flex items-center gap-1.5 truncate">
Expand Down
Loading

0 comments on commit 5252fd3

Please sign in to comment.