Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🤖 feat: Add Agent Duplication Functionality with Permission #4981

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading