From abf9fc307de0346efe984bcf3ca4aa07c214a2e5 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 25 Jun 2026 21:58:15 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=87=20feat:=20Agent=20Contact=20Visibi?= =?UTF-8?q?lity=20with=20Owner=20Fallback=20(#13663)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Shared Contract * Backend Resolution * Frontend Display * Contact Styling and more tests * fix contact flicker when saving an agent * fix display owner when contact deleted * simplification of the last fixes * github action fixes * fixes failing tests --------- Co-authored-by: Peter Rothlaender --- api/server/controllers/agents/v1.js | 24 ++- api/server/controllers/agents/v1.spec.js | 160 +++++++++++++++++- api/server/routes/agents/actions.js | 2 + api/server/services/Agents/ownerContact.js | 87 ++++++++++ client/src/components/Agents/AgentCard.tsx | 17 +- client/src/components/Agents/AgentContact.tsx | 39 +++++ .../components/Agents/AgentDetailContent.tsx | 50 +----- .../Agents/tests/AgentCard.spec.tsx | 126 +++++++++----- .../Agents/tests/AgentContact.spec.tsx | 71 ++++++++ .../Agents/tests/AgentDetailContent.spec.tsx | 135 +++++++++++++++ client/src/components/Chat/Landing.tsx | 11 +- .../__tests__/Landing.agent-contact.spec.tsx | 156 +++++++++++++++++ client/src/data-provider/Agents/mutations.ts | 3 +- client/src/locales/en/translation.json | 1 + packages/api/src/agents/contact.spec.ts | 60 +++++++ packages/api/src/agents/contact.ts | 55 ++++++ packages/api/src/agents/index.ts | 1 + .../data-provider/src/types/assistants.ts | 6 + packages/data-schemas/src/schema/index.ts | 1 + 19 files changed, 894 insertions(+), 111 deletions(-) create mode 100644 api/server/services/Agents/ownerContact.js create mode 100644 client/src/components/Agents/AgentContact.tsx create mode 100644 client/src/components/Agents/tests/AgentContact.spec.tsx create mode 100644 client/src/components/Agents/tests/AgentDetailContent.spec.tsx create mode 100644 client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx create mode 100644 packages/api/src/agents/contact.spec.ts create mode 100644 packages/api/src/agents/contact.ts diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5742a3ad7e..d19645d05f 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -48,6 +48,7 @@ const { resolveConfigServers, userCanUseMCPServers, } = require('~/server/services/MCP'); +const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact'); const { getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); const db = require('~/models'); @@ -517,13 +518,15 @@ const getAgentHandler = async (req, res, expandProperties = false) => { }); agent.isPublic = isPublic; + await attachOwnerContacts([agent]); + if (agent.author !== author) { delete agent.author; } if (!expandProperties) { // VIEW permission: Basic agent info only - return res.status(200).json({ + const responseAgent = { _id: agent._id, id: agent.id, name: agent.name, @@ -538,7 +541,16 @@ const getAgentHandler = async (req, res, expandProperties = false) => { // Safe metadata createdAt: agent.createdAt, updatedAt: agent.updatedAt, - }); + }; + + if (agent.support_contact !== undefined) { + responseAgent.support_contact = agent.support_contact; + } + if (agent.owner_contact !== undefined) { + responseAgent.owner_contact = agent.owner_contact; + } + + return res.status(200).json(responseAgent); } // EDIT permission: Full agent details including sensitive configuration @@ -710,6 +722,8 @@ const updateAgentHandler = async (req, res) => { updatedAgent.author = updatedAgent.author.toString(); } + await attachOwnerContacts([updatedAgent]); + if (updatedAgent.author !== req.user.id) { delete updatedAgent.author; } @@ -1045,9 +1059,10 @@ const getListAgentsHandler = async (req, res) => { } const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString())); + const agentsWithContacts = await attachOwnerContacts(agents); const urlCache = cachedRefresh?.urlCache; - data.data = agents.map((agent) => { + data.data = agentsWithContacts.map((agent) => { if (accessibleSkillSet) { sanitizeViewerSkillScope(agent, accessibleSkillSet); } @@ -1153,6 +1168,7 @@ const uploadAgentAvatarHandler = async (req, res) => { const updatedAgent = await db.updateAgent({ id: agent_id }, data, { updatingUserId: req.user.id, }); + await attachOwnerContacts([updatedAgent]); try { const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); @@ -1257,6 +1273,8 @@ const revertAgentVersionHandler = async (req, res) => { updatedAgent.author = updatedAgent.author.toString(); } + await attachOwnerContacts([updatedAgent]); + if (updatedAgent.author !== req.user.id) { delete updatedAgent.author; } diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index fda2bdd616..7710fbb2a5 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1,8 +1,14 @@ const mongoose = require('mongoose'); const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); -const { agentSchema, fileSchema } = require('@librechat/data-schemas'); -const { FileSources, PermissionBits, ResourceType } = require('librechat-data-provider'); +const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas'); +const { + FileSources, + PermissionBits, + PrincipalModel, + PrincipalType, + ResourceType, +} = require('librechat-data-provider'); const { MongoMemoryServer } = require('mongodb-memory-server'); // Only mock the dependencies that are not database-related @@ -91,6 +97,32 @@ const { refreshS3Url } = require('@librechat/api'); * @type {import('mongoose').Model} */ let Agent; +let AclEntry; +let User; + +const OWNER_PERMISSION_BITS = + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE; + +const createOwner = (overrides = {}) => + User.create({ + name: 'Agent Owner', + email: `owner-${nanoid(8)}@example.com`, + provider: 'local', + emailVerified: true, + ...overrides, + }); + +const grantAgentOwner = ({ agent, owner, grantedAt = new Date() }) => + AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: owner._id, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: OWNER_PERMISSION_BITS, + grantedBy: owner._id, + grantedAt, + }); describe('Agent Controllers - Mass Assignment Protection', () => { let mongoServer; @@ -102,6 +134,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const mongoUri = mongoServer.getUri(); await mongoose.connect(mongoUri); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema); + User = mongoose.models.User || mongoose.model('User', userSchema); // Register File so orphan-pruning tests (and the tool_resources validation // test, which now needs real File docs for its ids) have a working model. mongoose.models.File || mongoose.model('File', fileSchema); @@ -114,6 +148,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => { beforeEach(async () => { await Agent.deleteMany({}); + await AclEntry.deleteMany({}); + await User.deleteMany({}); await mongoose.models.File.deleteMany({}); // Reset all mocks @@ -510,6 +546,61 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.model_parameters.temperature).toBeUndefined(); expect(response.model_parameters.apiKey).toBeUndefined(); }); + + test('should return owner_contact from the first ACL owner when support_contact is missing', async () => { + const owner = await createOwner({ + name: 'Primary Owner', + email: 'primary.owner@example.com', + }); + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Owner Contact Agent', + description: 'Uses owner fallback', + provider: 'openai', + model: 'gpt-4', + author: owner._id, + }); + await grantAgentOwner({ agent, owner }); + + mockReq.params = { id: agent.id }; + + await getAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + const response = mockRes.json.mock.calls[0][0]; + expect(response.owner_contact).toEqual({ + name: 'Primary Owner', + email: 'primary.owner@example.com', + }); + }); + + test('should not return owner_contact when support_contact is present', async () => { + const owner = await createOwner({ + name: 'Primary Owner', + email: 'primary.owner@example.com', + }); + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Support Contact Agent', + description: 'Uses support contact', + provider: 'openai', + model: 'gpt-4', + author: owner._id, + support_contact: { name: 'Support Team', email: 'support@example.com' }, + }); + await grantAgentOwner({ agent, owner }); + + mockReq.params = { id: agent.id }; + + await getAgentHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.support_contact).toEqual({ + name: 'Support Team', + email: 'support@example.com', + }); + expect(response.owner_contact).toBeUndefined(); + }); }); describe('updateAgentHandler', () => { @@ -1303,6 +1394,71 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].name).toBe('Agent A1'); }); + test('should return owner_contact for list agents missing support_contact', async () => { + const owner = await createOwner({ + _id: userA, + name: 'List Owner', + email: 'list.owner@example.com', + }); + await grantAgentOwner({ agent: agentA1, owner }); + + mockReq.user.id = userB.toString(); + findAccessibleResources.mockResolvedValue([agentA1._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data[0].owner_contact).toEqual({ + name: 'List Owner', + email: 'list.owner@example.com', + }); + }); + + test('should use the first ACL owner when an agent has multiple owners', async () => { + const firstOwner = await createOwner({ + name: 'First Owner', + email: 'first.owner@example.com', + }); + const secondOwner = await createOwner({ + name: 'Second Owner', + email: 'second.owner@example.com', + }); + await grantAgentOwner({ + agent: agentA1, + owner: secondOwner, + grantedAt: new Date('2024-02-01T00:00:00.000Z'), + }); + await grantAgentOwner({ + agent: agentA1, + owner: firstOwner, + grantedAt: new Date('2024-01-01T00:00:00.000Z'), + }); + + mockReq.user.id = userB.toString(); + findAccessibleResources.mockResolvedValue([agentA1._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data[0].owner_contact).toEqual({ + name: 'First Owner', + email: 'first.owner@example.com', + }); + }); + + test('should omit owner_contact when no owner user can be resolved', async () => { + mockReq.user.id = userB.toString(); + findAccessibleResources.mockResolvedValue([agentA1._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data[0].owner_contact).toBeUndefined(); + }); + test('should return only expected safe list fields for VIEW callers', async () => { const hiddenSkillId = new mongoose.Types.ObjectId(); await Agent.findByIdAndUpdate(agentA1._id, { diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index eab7c908aa..e2e6ff1a4f 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -22,6 +22,7 @@ const { } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { findAccessibleResources } = require('~/server/services/PermissionService'); +const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact'); const db = require('~/models'); const { canAccessAgentResource } = require('~/server/middleware'); @@ -209,6 +210,7 @@ router.post( forceVersion: true, }, ); + await attachOwnerContacts([updatedAgent]); // Only update user field for new actions const actionUpdateData = { diff --git a/api/server/services/Agents/ownerContact.js b/api/server/services/Agents/ownerContact.js new file mode 100644 index 0000000000..f73e704276 --- /dev/null +++ b/api/server/services/Agents/ownerContact.js @@ -0,0 +1,87 @@ +const { logger } = require('@librechat/data-schemas'); +const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider'); +const { hasSupportContact, resolveAgentOwnerContact } = require('@librechat/api'); +const db = require('~/models'); + +const OWNER_PERMISSION_BITS = + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE; + +const getFirstOwnerIdsByResource = async (agents) => { + const resourceIds = agents + .filter((agent) => !hasSupportContact(agent)) + .map((agent) => agent?._id) + .filter(Boolean); + + if (resourceIds.length === 0) { + return new Map(); + } + + try { + const entries = await db.aggregateAclEntries([ + { + $match: { + resourceType: ResourceType.AGENT, + resourceId: { $in: resourceIds }, + principalType: PrincipalType.USER, + permBits: OWNER_PERMISSION_BITS, + }, + }, + { $sort: { grantedAt: 1, createdAt: 1, _id: 1 } }, + { $group: { _id: '$resourceId', principalId: { $first: '$principalId' } } }, + ]); + + return new Map( + entries + .map((entry) => [entry?._id?.toString(), entry?.principalId?.toString()]) + .filter(([resourceId, ownerId]) => resourceId && ownerId), + ); + } catch (error) { + logger.warn('[/Agents] Failed to resolve agent owner ACL entries', error); + return new Map(); + } +}; + +const attachOwnerContacts = async (agents) => { + if (!Array.isArray(agents) || agents.length === 0) { + return agents; + } + + const ownerIdsByResource = await getFirstOwnerIdsByResource(agents); + const ownerIds = [ + ...new Set( + agents + .filter((agent) => !hasSupportContact(agent)) + .map((agent) => ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString()) + .filter(Boolean), + ), + ]; + + let ownersById = new Map(); + if (ownerIds.length > 0) { + try { + const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username email'); + ownersById = new Map(users.map((user) => [user?._id?.toString(), user])); + } catch (error) { + logger.warn('[/Agents] Failed to resolve agent owner users', error); + } + } + + return agents.map((agent) => { + if (hasSupportContact(agent)) { + delete agent.owner_contact; + return agent; + } + const ownerId = ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString(); + const ownerContact = resolveAgentOwnerContact(agent, ownersById.get(ownerId) ?? null); + if (ownerContact) { + agent.owner_contact = ownerContact; + } else { + delete agent.owner_contact; + } + return agent; + }); +}; + +module.exports = { + attachOwnerContacts, +}; diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index 7e9dd6da10..cdaa9f9d44 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -2,8 +2,9 @@ import React, { useMemo, useState } from 'react'; import { Label, OGDialog, OGDialogTrigger } from '@librechat/client'; import type t from 'librechat-data-provider'; import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks'; -import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils'; import AgentDetailContent from './AgentDetailContent'; +import { cn, renderAgentAvatar } from '~/utils'; +import AgentContact from './AgentContact'; interface AgentCardProps { agent: t.Agent; @@ -33,8 +34,6 @@ const AgentCard: React.FC = ({ agent, onSelect, className = '' } return agent.category.charAt(0).toUpperCase() + agent.category.slice(1); }, [agent.category, categories, localize]); - const displayName = getContactDisplayName(agent); - const handleOpenChange = (open: boolean) => { setIsOpen(open); if (open && onSelect) { @@ -102,14 +101,10 @@ const AgentCard: React.FC = ({ agent, onSelect, className = '' }

)} - {/* Author */} - {displayName && ( -
- - {localize('com_ui_by_author', { 0: displayName || '' })} - -
- )} + diff --git a/client/src/components/Agents/AgentContact.tsx b/client/src/components/Agents/AgentContact.tsx new file mode 100644 index 0000000000..73f8524165 --- /dev/null +++ b/client/src/components/Agents/AgentContact.tsx @@ -0,0 +1,39 @@ +import type t from 'librechat-data-provider'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +type AgentContactProps = { + agent?: Pick | null; + className?: string; +}; + +export default function AgentContact({ agent, className = '' }: AgentContactProps) { + const localize = useLocalize(); + const supportName = agent?.support_contact?.name?.trim() ?? ''; + const supportEmail = agent?.support_contact?.email?.trim() ?? ''; + const ownerName = agent?.owner_contact?.name?.trim() ?? ''; + const ownerEmail = agent?.owner_contact?.email?.trim() ?? ''; + let contact: { name: string; email: string } | null = null; + if (supportName || supportEmail) { + contact = { name: supportName, email: supportEmail }; + } else if (ownerName || ownerEmail) { + contact = { name: ownerName, email: ownerEmail }; + } + + const label = contact?.name || contact?.email || localize('com_agents_no_contact_available'); + + return ( +
+ {localize('com_agents_contact')}: + + {contact?.email ? ( + + {label} + + ) : ( + label + )} + +
+ ); +} diff --git a/client/src/components/Agents/AgentDetailContent.tsx b/client/src/components/Agents/AgentDetailContent.tsx index 7a2a95401d..356da52df1 100644 --- a/client/src/components/Agents/AgentDetailContent.tsx +++ b/client/src/components/Agents/AgentDetailContent.tsx @@ -14,18 +14,10 @@ import type t from 'librechat-data-provider'; import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks'; import { renderAgentAvatar, clearMessagesCache } from '~/utils'; import { useChatContext } from '~/Providers'; - -interface SupportContact { - name?: string; - email?: string; -} - -interface AgentWithSupport extends t.Agent { - support_contact?: SupportContact; -} +import AgentContact from './AgentContact'; interface AgentDetailContentProps { - agent: AgentWithSupport; + agent: t.Agent; } /** @@ -106,37 +98,6 @@ const AgentDetailContent: React.FC = ({ agent }) => { }); }; - /** - * Format contact information with mailto links when appropriate - */ - const formatContact = () => { - if (!agent?.support_contact) return null; - - const { name, email } = agent.support_contact; - - if (name && email) { - return ( - - {name} - - ); - } - - if (email) { - return ( - - {email} - - ); - } - - if (name) { - return {name}; - } - - return null; - }; - return ( {/* Agent avatar */} @@ -149,12 +110,7 @@ const AgentDetailContent: React.FC = ({ agent }) => { - {/* Contact info */} - {agent?.support_contact && formatContact() && ( -
- {localize('com_agents_contact')}: {formatContact()} -
- )} + {/* Agent description */}
diff --git a/client/src/components/Agents/tests/AgentCard.spec.tsx b/client/src/components/Agents/tests/AgentCard.spec.tsx index 5e16f3d265..1d968f34ea 100644 --- a/client/src/components/Agents/tests/AgentCard.spec.tsx +++ b/client/src/components/Agents/tests/AgentCard.spec.tsx @@ -1,9 +1,25 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import AgentCard from '../AgentCard'; -import type t from 'librechat-data-provider'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; +import AgentCard from '../AgentCard'; + +jest.mock('~/utils', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require('react'); + return { + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + renderAgentAvatar: (agent: any) => { + const avatar = agent.avatar; + const src = typeof avatar === 'string' ? avatar : avatar?.filepath; + if (src) { + return {`${agent.name}; + } + return ; + }, + }; +}); // Mock useLocalize hook jest.mock('~/hooks/useLocalize', () => () => (key: string) => { @@ -12,7 +28,8 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => { com_agents_agent_card_label: '{{name}} agent. {{description}}', com_agents_category_general: 'General', com_agents_category_hr: 'Human Resources', - com_ui_by_author: 'by {{0}}', + com_agents_contact: 'Contact', + com_agents_no_contact_available: 'No contact available', com_agents_description_card: '{{description}}', }; return mockTranslations[key] || key; @@ -26,7 +43,8 @@ jest.mock('~/hooks', () => ({ com_agents_agent_card_label: '{{name}} agent. {{description}}', com_agents_category_general: 'General', com_agents_category_hr: 'Human Resources', - com_ui_by_author: 'by {{0}}', + com_agents_contact: 'Contact', + com_agents_no_contact_available: 'No contact available', com_agents_description_card: '{{description}}', }; let translation = mockTranslations[key] || key; @@ -73,46 +91,52 @@ jest.mock('~/Providers', () => ({ })); // Mock @librechat/client with proper Dialog behavior -jest.mock('@librechat/client', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require('react'); - return { - ...jest.requireActual('@librechat/client'), - useToastContext: jest.fn(() => ({ - showToast: jest.fn(), - })), - OGDialog: ({ children, open, onOpenChange }: any) => { - // Store onOpenChange in context for trigger to call - return ( -
- {React.Children.map(children, (child: any) => { - if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) { - return React.cloneElement(child, { onOpenChange }); - } - // Only render content when open - if (child?.type?.displayName === 'OGDialogContent' && !open) { - return null; - } - return child; - })} -
- ); - }, - OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => { - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement, { - onClick: (e: any) => { - (children as any).props?.onClick?.(e); - onOpenChange?.(true); - }, - }); - } - return
onOpenChange?.(true)}>{children}
; - }, - OGDialogContent: ({ children }: any) =>
{children}
, - Label: ({ children, className }: any) => {children}, - }; -}); +jest.mock( + '@librechat/client', + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require('react'); + return { + useToastContext: jest.fn(() => ({ + showToast: jest.fn(), + })), + OGDialog: ({ children, open, onOpenChange }: any) => { + // Store onOpenChange in context for trigger to call + return ( +
+ {React.Children.map(children, (child: any) => { + if ( + child?.type?.displayName === 'OGDialogTrigger' || + child?.props?.['data-trigger'] + ) { + return React.cloneElement(child, { onOpenChange }); + } + // Only render content when open + if (child?.type?.displayName === 'OGDialogContent' && !open) { + return null; + } + return child; + })} +
+ ); + }, + OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement, { + onClick: (e: any) => { + (children as any).props?.onClick?.(e); + onOpenChange?.(true); + }, + }); + } + return
onOpenChange?.(true)}>{children}
; + }, + OGDialogContent: ({ children }: any) =>
{children}
, + Label: ({ children, className }: any) => {children}, + }; + }, + { virtual: true }, +); // Create wrapper with QueryClient const createWrapper = () => { @@ -290,13 +314,18 @@ describe('AgentCard', () => { expect(screen.getByText('Test Agent')).toBeInTheDocument(); expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument(); + expect(screen.getByText('No contact available')).toBeInTheDocument(); }); - it('displays authorName when support_contact is missing', () => { + it('falls back to owner contact when support_contact is missing', () => { const agentWithAuthorName = { ...mockAgent, support_contact: undefined, authorName: 'John Doe', + owner_contact: { + name: 'Owner User', + email: 'owner@example.com', + }, }; render( @@ -305,7 +334,12 @@ describe('AgentCard', () => { , ); - expect(screen.getByText('by John Doe')).toBeInTheDocument(); + expect(screen.getByText('Contact:')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute( + 'href', + 'mailto:owner@example.com', + ); + expect(screen.queryByText('by John Doe')).not.toBeInTheDocument(); }); it('has proper accessibility attributes', () => { diff --git a/client/src/components/Agents/tests/AgentContact.spec.tsx b/client/src/components/Agents/tests/AgentContact.spec.tsx new file mode 100644 index 0000000000..5225f6c85c --- /dev/null +++ b/client/src/components/Agents/tests/AgentContact.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AgentContact from '../AgentContact'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => { + const translations: Record = { + com_agents_contact: 'Contact', + com_agents_no_contact_available: 'No contact available', + }; + return translations[key] || key; + }, +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), +})); + +describe('AgentContact', () => { + it('uses support contact before owner contact', () => { + render( + , + ); + + expect(screen.getByText('Contact:')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute( + 'href', + 'mailto:support@example.com', + ); + expect(screen.queryByText('Owner User')).not.toBeInTheDocument(); + }); + + it('falls back to owner contact', () => { + render( + , + ); + + expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute( + 'href', + 'mailto:owner@example.com', + ); + }); + + it('renders a plain name when no email is available', () => { + render(); + + expect(screen.getByText('Owner User')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders no-contact text when no contact is available', () => { + render(); + + expect(screen.getByText('No contact available')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Agents/tests/AgentDetailContent.spec.tsx b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx new file mode 100644 index 0000000000..19a2b5ef6d --- /dev/null +++ b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom'; +import AgentDetailContent from '../AgentDetailContent'; + +jest.mock('librechat-data-provider', () => ({ + QueryKeys: { + agents: 'agents', + messages: 'messages', + }, + Constants: { + NEW_CONVO: 'new', + }, + EModelEndpoint: { + agents: 'agents', + }, + PermissionBits: { + EDIT: 2, + }, + LocalStorageKeys: { + AGENT_ID_PREFIX: 'agent:', + }, +})); + +jest.mock( + '@librechat/client', + () => ({ + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + useToastContext: () => ({ + showToast: jest.fn(), + }), + }), + { virtual: true }, +); + +jest.mock('~/hooks', () => ({ + useDefaultConvo: () => jest.fn((value) => value.conversation), + useFavorites: () => ({ + isFavoriteAgent: jest.fn(() => false), + toggleFavoriteAgent: jest.fn(), + }), + useLocalize: () => (key: string, values?: Record) => { + const translations: Record = { + com_agents_contact: 'Contact', + com_agents_no_contact_available: 'No contact available', + com_agents_loading: 'Loading', + com_agents_link_copied: 'Link copied', + com_agents_link_copy_failed: 'Link copy failed', + com_agents_start_chat: 'Start chat', + com_agents_chat_with: `Chat with ${values?.name ?? ''}`, + com_ui_agent: 'Agent', + com_ui_pin: 'Pin', + com_ui_unpin: 'Unpin', + com_agents_copy_link: 'Copy link', + }; + return translations[key] || key; + }, +})); + +jest.mock('~/Providers', () => ({ + useChatContext: () => ({ + conversation: undefined, + newConversation: jest.fn(), + }), +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + clearMessagesCache: jest.fn(), + renderAgentAvatar: () =>
, +})); + +const renderWithClient = (children: React.ReactNode) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render({children}); +}; + +const baseAgent = { + id: 'agent-1', + name: 'Agent One', + description: 'Agent description', + provider: 'openai', + model: 'gpt-4', + model_parameters: {}, +}; + +describe('AgentDetailContent', () => { + it('renders support contact with mailto link', () => { + renderWithClient( + , + ); + + expect(screen.getByText('Contact:')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute( + 'href', + 'mailto:support@example.com', + ); + expect(screen.queryByText('Owner User')).not.toBeInTheDocument(); + }); + + it('falls back to owner contact when support contact is missing', () => { + renderWithClient( + , + ); + + expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute( + 'href', + 'mailto:owner@example.com', + ); + }); +}); diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index d3a3d3c8b1..ac333198cd 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -12,6 +12,7 @@ import { } from '~/utils'; import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers'; import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import AgentContact from '~/components/Agents/AgentContact'; import ConvoIcon from '~/components/Endpoints/ConvoIcon'; import { useLocalize, useAuthContext } from '~/hooks'; @@ -88,6 +89,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: }), [], ); + const selectedAgent = + isAgent && conversation?.agent_id != null ? agentsMap?.[conversation.agent_id] : undefined; const getGreeting = useCallback(() => { if (typeof startupConfig?.interface?.customWelcome === 'string') { @@ -135,7 +138,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: if (contentRef.current) { setContentHeight(contentRef.current.offsetHeight); } - }, [lineCount, description]); + }, [lineCount, description, selectedAgent]); const getDynamicMargin = useMemo(() => { let margin = 'mb-0'; @@ -234,6 +237,12 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: {description}
))} + {selectedAgent && ( + + )}
); diff --git a/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx new file mode 100644 index 0000000000..d954c3c91b --- /dev/null +++ b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Landing from '../Landing'; + +let mockConversation: Record | null = null; +let mockAgentsMap: Record | undefined; +let mockAssistantMap: Record | undefined; + +jest.mock('@react-spring/web', () => ({ + easings: { + easeOutCubic: jest.fn(), + }, +})); + +jest.mock('librechat-data-provider', () => ({ + EModelEndpoint: { + azureOpenAI: 'azureOpenAI', + openAI: 'openAI', + }, +})); + +jest.mock( + '@librechat/client', + () => ({ + BirthdayIcon: () => , + TooltipAnchor: ({ children }: { children: React.ReactNode }) => {children}, + SplitText: ({ text }: { text: string }) => {text}, + }), + { virtual: true }, +); + +jest.mock('~/Providers', () => ({ + useChatContext: () => ({ conversation: mockConversation }), + useAgentsMapContext: () => mockAgentsMap, + useAssistantsMapContext: () => mockAssistantMap, +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => ({ data: { interface: {} } }), + useGetEndpointsQuery: () => ({ data: {} }), +})); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => ({ user: undefined }), + useLocalize: () => (key: string) => { + const translations: Record = { + com_agents_contact: 'Contact', + com_agents_no_contact_available: 'No contact available', + com_ui_good_morning: 'Good morning', + com_ui_good_afternoon: 'Good afternoon', + com_ui_good_evening: 'Good evening', + com_ui_late_night: 'Good evening', + com_ui_weekend_morning: 'Good morning', + }; + return translations[key] || key; + }, +})); + +jest.mock('~/utils', () => ({ + CONFIG_HTML_MEDIA_ATTR: {}, + CONFIG_HTML_MEDIA_TAGS: [], + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + createConfigHtmlSanitizer: () => (html: string) => html, + getIconEndpoint: ({ endpoint }: { endpoint: string }) => endpoint, + getModelSpec: () => undefined, + getEntity: ({ + endpoint, + agentsMap, + assistantMap, + agent_id, + assistant_id, + }: { + endpoint: string; + agentsMap?: Record; + assistantMap?: Record; + agent_id?: string; + assistant_id?: string; + }) => { + if (endpoint === 'agents' && agent_id != null) { + return { entity: agentsMap?.[agent_id], isAgent: true, isAssistant: false }; + } + if (assistant_id != null) { + return { entity: assistantMap?.[assistant_id], isAgent: false, isAssistant: true }; + } + return { entity: undefined, isAgent: false, isAssistant: false }; + }, +})); + +jest.mock('~/components/Endpoints/ConvoIcon', () => () => ); + +describe('Landing agent contact', () => { + beforeEach(() => { + mockConversation = null; + mockAgentsMap = undefined; + mockAssistantMap = undefined; + }); + + it('shows contact for the selected agent from agentsMap', () => { + mockConversation = { + endpoint: 'agents', + agent_id: 'agent-1', + }; + mockAgentsMap = { + 'agent-1': { + id: 'agent-1', + name: 'Portal Remote Agent', + description: 'Remote Agent Showcase', + owner_contact: { name: 'Owner User', email: 'owner@example.com' }, + }, + }; + + render(); + + expect(screen.getByText('Portal Remote Agent')).toBeInTheDocument(); + expect(screen.getByText('Remote Agent Showcase')).toBeInTheDocument(); + expect(screen.getByText('Contact:')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute( + 'href', + 'mailto:owner@example.com', + ); + }); + + it('does not show contact when the selected agent is missing from agentsMap', () => { + mockConversation = { + endpoint: 'agents', + agent_id: 'missing-agent', + greeting: 'Start chatting', + }; + mockAgentsMap = {}; + + render(); + + expect(screen.queryByText('Contact:')).not.toBeInTheDocument(); + expect(screen.queryByText('No contact available')).not.toBeInTheDocument(); + }); + + it('does not show contact for assistants', () => { + mockConversation = { + endpoint: 'assistants', + assistant_id: 'assistant-1', + }; + mockAssistantMap = { + 'assistant-1': { + id: 'assistant-1', + name: 'Assistant', + description: 'Assistant description', + }, + }; + + render(); + + expect(screen.getByText('Assistant')).toBeInTheDocument(); + expect(screen.queryByText('Contact:')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index f49c910c98..951e694ec8 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { dataService, MutationKeys, PermissionBits, QueryKeys } from 'librechat-data-provider'; -import type * as t from 'librechat-data-provider'; import type { QueryClient, UseMutationResult } from '@tanstack/react-query'; +import type * as t from 'librechat-data-provider'; /** * AGENTS @@ -10,6 +10,7 @@ export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [ { requiredPermission: PermissionBits.VIEW }, { requiredPermission: PermissionBits.EDIT }, ]; + /** * Create a new agent */ diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f602a7c352..b2debb8de9 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -34,6 +34,7 @@ "com_agents_chat_with": "Chat with {{name}}", "com_agents_clear_search": "Clear search", "com_agents_contact": "Contact", + "com_agents_no_contact_available": "No contact available", "com_agents_copy_link": "Copy Link", "com_agents_create_error": "There was an error creating your agent.", "com_agents_created_by": "by", diff --git a/packages/api/src/agents/contact.spec.ts b/packages/api/src/agents/contact.spec.ts new file mode 100644 index 0000000000..1ac5d81ae2 --- /dev/null +++ b/packages/api/src/agents/contact.spec.ts @@ -0,0 +1,60 @@ +import { resolveAgentOwnerContact } from './contact'; + +describe('resolveAgentOwnerContact', () => { + it('omits owner fallback when support contact has a name', () => { + const result = resolveAgentOwnerContact( + { support_contact: { name: 'Support Team' } }, + { name: 'Agent Owner', email: 'owner@example.com' }, + ); + + expect(result).toBeUndefined(); + }); + + it('omits owner fallback when support contact has an email', () => { + const result = resolveAgentOwnerContact( + { support_contact: { email: 'support@example.com' } }, + { name: 'Agent Owner', email: 'owner@example.com' }, + ); + + expect(result).toBeUndefined(); + }); + + it('uses owner name and email when support contact is empty', () => { + const result = resolveAgentOwnerContact( + { support_contact: { name: ' ', email: '' } }, + { name: ' Agent Owner ', email: ' owner@example.com ' }, + ); + + expect(result).toEqual({ name: 'Agent Owner', email: 'owner@example.com' }); + }); + + it('falls back to username for owner display name', () => { + const result = resolveAgentOwnerContact({}, { username: 'owner.user' }); + + expect(result).toEqual({ name: 'owner.user' }); + }); + + it('falls back to authorName when owner has no display name', () => { + const result = resolveAgentOwnerContact( + { authorName: 'Legacy Author' }, + { email: 'owner@example.com' }, + ); + + expect(result).toEqual({ name: 'Legacy Author', email: 'owner@example.com' }); + }); + + it('omits owner contact when no owner can be resolved', () => { + const result = resolveAgentOwnerContact({ authorName: 'Legacy Author' }, null); + + expect(result).toBeUndefined(); + }); + + it('omits owner contact when all candidate fields are empty', () => { + const result = resolveAgentOwnerContact( + { authorName: ' ' }, + { name: '', username: ' ', email: ' ' }, + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/api/src/agents/contact.ts b/packages/api/src/agents/contact.ts new file mode 100644 index 0000000000..e5a9824e0c --- /dev/null +++ b/packages/api/src/agents/contact.ts @@ -0,0 +1,55 @@ +import type { AgentOwnerContact } from 'librechat-data-provider'; + +export interface AgentContactSource { + authorName?: string | null; + support_contact?: { + name?: string | null; + email?: string | null; + } | null; +} + +export interface AgentOwnerContactSource { + name?: string | null; + username?: string | null; + email?: string | null; +} + +const normalizeContactValue = (value?: string | null): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +export const hasSupportContact = (agent: AgentContactSource): boolean => { + const support = agent.support_contact; + if (!support) { + return false; + } + return !!normalizeContactValue(support.name) || !!normalizeContactValue(support.email); +}; + +export function resolveAgentOwnerContact( + agent: AgentContactSource, + owner: AgentOwnerContactSource | null, +): AgentOwnerContact | undefined { + if (hasSupportContact(agent) || owner == null) { + return undefined; + } + + const name = + normalizeContactValue(owner.name) ?? + normalizeContactValue(owner.username) ?? + normalizeContactValue(agent.authorName); + const email = normalizeContactValue(owner.email); + + if (!name && !email) { + return undefined; + } + + return { + ...(name ? { name } : {}), + ...(email ? { email } : {}), + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index fc14367e3e..a94eb39d9d 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -3,6 +3,7 @@ export * from './attachments'; export * from './chain'; export * from './client'; export * from './config'; +export * from './contact'; export * from './context'; export * from './discovery'; export * from './edges'; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index b222c718ed..bae2a7a72f 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -209,6 +209,11 @@ export type SupportContact = { email?: string; }; +export type AgentOwnerContact = { + name?: string; + email?: string; +}; + /** * Specifies who can invoke a tool. * - 'direct': LLM can call directly @@ -286,6 +291,7 @@ export type Agent = { version?: number; category?: string; support_contact?: SupportContact; + owner_contact?: AgentOwnerContact; /** Per-tool configuration options (deferred loading, allowed callers, etc.) */ tool_options?: AgentToolOptions; /** Optional allowlist of skill ObjectIds. Only applies when `skills_enabled`. */ diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 08471bf5c8..a71591844f 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -1,5 +1,6 @@ export { default as actionSchema } from './action'; export { default as agentSchema } from './agent'; +export { default as aclEntrySchema } from './aclEntry'; export { default as agentApiKeySchema } from './agentApiKey'; export { default as agentCategorySchema } from './agentCategory'; export { default as assistantSchema } from './assistant';