diff --git a/api/models/Agent.js b/api/models/Agent.js index 5f448502a51..077ac9d57ad 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -20,7 +20,7 @@ const Agent = mongoose.model('agent', agentSchema); * @throws {Error} If the agent creation fails. */ const createAgent = async (agentData) => { - return await Agent.create(agentData); + return (await Agent.create(agentData)).toObject(); }; /** diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5212e9795b6..08327ec61c9 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -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, @@ -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'); @@ -173,6 +180,99 @@ 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) => { + const { id } = req.params; + const { id: userId } = req.user; + const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; + + try { + const agent = await getAgent({ id }); + if (!agent) { + return res.status(404).json({ + error: 'Agent not found', + status: 'error', + }); + } + + const { + _id: __id, + id: _id, + author: _author, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...cloneData + } = agent; + + const newAgentId = `agent_${nanoid()}`; + const newAgentData = Object.assign(cloneData, { + id: newAgentId, + author: userId, + }); + + const newActionsList = []; + const originalActions = (await getActions({ agent_id: id }, true)) ?? []; + const promises = []; + + /** + * Duplicates an action and returns the new action ID. + * @param {Action} action + * @returns {Promise} + */ + const duplicateAction = async (action) => { + const newActionId = nanoid(); + const [domain] = action.action_id.split(actionDelimiter); + const fullActionId = `${domain}${actionDelimiter}${newActionId}`; + + const newAction = await updateAction( + { action_id: newActionId }, + { + metadata: action.metadata, + agent_id: newAgentId, + user: userId, + }, + ); + + const filteredMetadata = { ...newAction.metadata }; + for (const field of sensitiveFields) { + delete filteredMetadata[field]; + } + + newAction.metadata = filteredMetadata; + newActionsList.push(newAction); + return fullActionId; + }; + + for (const action of originalActions) { + promises.push( + duplicateAction(action).catch((error) => { + logger.error('[/agents/:id/duplicate] Error duplicating Action:', error); + }), + ); + } + + const agentActions = await Promise.all(promises); + newAgentData.actions = agentActions; + const newAgent = await createAgent(newAgentData); + + return res.status(201).json({ + agent: newAgent, + actions: newActionsList, + }); + } 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 @@ -292,6 +392,7 @@ module.exports = { createAgent: createAgentHandler, getAgent: getAgentHandler, updateAgent: updateAgentHandler, + duplicateAgent: duplicateAgentHandler, deleteAgent: deleteAgentHandler, getListAgents: getListAgentsHandler, uploadAgentAvatar: uploadAgentAvatarHandler, diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 2a275c12044..f79cec2cdc7 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -62,6 +62,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', checkAgentCreate, v1.duplicateAgent); + /** * Deletes an agent. * @route DELETE /agents/:id diff --git a/client/src/components/Nav/NavToggle.tsx b/client/src/components/Nav/NavToggle.tsx index 23b4f0285c2..c082c70f25d 100644 --- a/client/src/components/Nav/NavToggle.tsx +++ b/client/src/components/Nav/NavToggle.tsx @@ -1,4 +1,4 @@ -import { useLocalize, useLocalStorage } from '~/hooks'; +import { useLocalize } from '~/hooks'; import { TooltipAnchor } from '~/components/ui'; import { cn } from '~/utils'; diff --git a/client/src/components/SidePanel/Agents/AdminSettings.tsx b/client/src/components/SidePanel/Agents/AdminSettings.tsx index 5b0408c6161..d1fd697d002 100644 --- a/client/src/components/SidePanel/Agents/AdminSettings.tsx +++ b/client/src/components/SidePanel/Agents/AdminSettings.tsx @@ -142,7 +142,7 @@ const AdminSettings = () => { + )} {!canEditAgent && ( diff --git a/client/src/components/SidePanel/Agents/AgentPanelSkeleton.tsx b/client/src/components/SidePanel/Agents/AgentPanelSkeleton.tsx index a5575c972f7..1e67e8d1c8a 100644 --- a/client/src/components/SidePanel/Agents/AgentPanelSkeleton.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanelSkeleton.tsx @@ -6,8 +6,8 @@ export default function AgentPanelSkeleton() {
{/* Agent Select and Button */}
- - + +
@@ -17,52 +17,60 @@ export default function AgentPanelSkeleton() {
{/* Name */} - - - + + +
{/* Description */}
- - + +
{/* Instructions */}
- - + +
{/* Model and Provider */}
- - + +
{/* Capabilities */}
- - - + + + + +
{/* Tools & Actions */}
- - - + + +
- - + +
+ {/* Admin Settings */} +
+ +
+ {/* Bottom Buttons */}
- - - + + + +
diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index e8aabc2c6d9..7e31c5298ab 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -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', + '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={() => ( diff --git a/client/src/components/SidePanel/Agents/DuplicateAgent.tsx b/client/src/components/SidePanel/Agents/DuplicateAgent.tsx new file mode 100644 index 00000000000..35ffd517546 --- /dev/null +++ b/client/src/components/SidePanel/Agents/DuplicateAgent.tsx @@ -0,0 +1,50 @@ +import { CopyIcon } from 'lucide-react'; +import { useDuplicateAgentMutation } from '~/data-provider'; +import { cn, removeFocusOutlines } from '~/utils'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; + +export default function DuplicateAgent({ agent_id }: { agent_id: string }) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + + const duplicateAgent = useDuplicateAgentMutation({ + onSuccess: () => { + showToast({ + message: localize('com_ui_agent_duplicated'), + status: 'success', + }); + }, + onError: (error) => { + console.error(error); + showToast({ + message: localize('com_ui_agent_duplicate_error'), + status: 'error', + }); + }, + }); + + if (!agent_id) { + return null; + } + + const handleDuplicate = () => { + duplicateAgent.mutate({ agent_id }); + }; + + return ( + + ); +} diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx index 9b6a6aaf5c0..e86c261ac8f 100644 --- a/client/src/components/ui/Button.tsx +++ b/client/src/components/ui/Button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '~/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 8664b242c90..04f40cd3dd9 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -121,6 +121,43 @@ export const useDeleteAgentMutation = ( ); }; +/** + * Hook for duplicating an agent + */ +export const useDuplicateAgentMutation = ( + options?: t.DuplicateAgentMutationOptions, +): UseMutationResult<{ agent: t.Agent; actions: t.Action[] }, Error, t.DuplicateAgentBody> => { + const queryClient = useQueryClient(); + + return useMutation<{ agent: t.Agent; actions: t.Action[] }, Error, t.DuplicateAgentBody>( + (params: t.DuplicateAgentBody) => dataService.duplicateAgent(params), + { + onMutate: options?.onMutate, + onError: options?.onError, + onSuccess: ({ agent, actions }, variables, context) => { + const listRes = queryClient.getQueryData([ + QueryKeys.agents, + defaultOrderQuery, + ]); + + if (listRes) { + const currentAgents = [agent, ...listRes.data]; + queryClient.setQueryData([QueryKeys.agents, defaultOrderQuery], { + ...listRes, + data: currentAgents, + }); + } + + const existingActions = queryClient.getQueryData([QueryKeys.actions]) || []; + + queryClient.setQueryData([QueryKeys.actions], existingActions.concat(actions)); + + return options?.onSuccess?.({ agent, actions }, variables, context); + }, + }, + ); +}; + /** * Hook for uploading an agent avatar */ diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 97174155727..7f988301a78 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -361,6 +361,8 @@ export default { com_ui_agents_allow_share_global: 'Allow sharing Agents to all users', com_ui_agents_allow_use: 'Allow using Agents', com_ui_agents_allow_create: 'Allow creating Agents', + com_ui_agent_duplicated: 'Agent duplicated successfully', + com_ui_agent_duplicate_error: 'There was an error duplicating the agent', com_ui_prompt_already_shared_to_all: 'This prompt is already shared to all users', com_ui_description_placeholder: 'Optional: Enter a description to display for the prompt', com_ui_command_placeholder: 'Optional: Enter a command for the prompt or name will be used.', @@ -428,6 +430,8 @@ export default { com_ui_no_bookmarks: 'it seems like you have no bookmarks yet. Click on a chat and add a new one', com_ui_no_conversation_id: 'No conversation ID found', com_ui_add_multi_conversation: 'Add multi-conversation', + com_ui_duplicate: 'Duplicate', + com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?', com_auth_error_login: 'Unable to login with the information provided. Please check your credentials and try again.', com_auth_error_login_rl: diff --git a/package-lock.json b/package-lock.json index d86610651fc..117c397c674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36498,7 +36498,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.63", + "version": "0.7.64", "license": "ISC", "dependencies": { "axios": "^1.7.7", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index dcdfc0e127a..66c3fd80d50 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.63", + "version": "0.7.64", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index d0c6a80818f..1abddac4d4b 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -428,6 +428,16 @@ export const updateAgent = ({ ); }; +export const duplicateAgent = ({ + agent_id, +}: m.DuplicateAgentBody): Promise<{ agent: a.Agent; actions: a.Action[] }> => { + return request.post( + endpoints.agents({ + path: `${agent_id}/duplicate`, + }), + ); +}; + export const deleteAgent = ({ agent_id }: m.DeleteAgentBody): Promise => { return request.delete( endpoints.agents({ diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index f5d867d7252..73c8958fb7b 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -126,6 +126,15 @@ export type UpdateAgentVariables = { export type UpdateAgentMutationOptions = MutationOptions; +export type DuplicateAgentBody = { + agent_id: string; +}; + +export type DuplicateAgentMutationOptions = MutationOptions< + { agent: Agent; actions: Action[] }, + Pick +>; + export type DeleteAgentBody = { agent_id: string; };