mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
📇 feat: Agent Contact Visibility with Owner Fallback (#13663)
* 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 <peter.rothlaender@ginkgo.com>
This commit is contained in:
parent
376370d610
commit
abf9fc307d
19 changed files with 894 additions and 111 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<import('@librechat/data-schemas').IAgent>}
|
||||
*/
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
87
api/server/services/Agents/ownerContact.js
Normal file
87
api/server/services/Agents/ownerContact.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<AgentCardProps> = ({ 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<AgentCardProps> = ({ agent, onSelect, className = '' }
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* Author */}
|
||||
{displayName && (
|
||||
<div className="mt-1 text-xs text-text-tertiary">
|
||||
<span className="truncate">
|
||||
{localize('com_ui_by_author', { 0: displayName || '' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AgentContact
|
||||
agent={agent}
|
||||
className="mt-1 text-xs text-text-secondary [&_a]:font-normal [&_a]:text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogTrigger>
|
||||
|
|
|
|||
39
client/src/components/Agents/AgentContact.tsx
Normal file
39
client/src/components/Agents/AgentContact.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type AgentContactProps = {
|
||||
agent?: Pick<t.Agent, 'support_contact' | 'owner_contact'> | 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 (
|
||||
<div className={cn('flex min-w-0 items-center gap-1 text-text-secondary', className)}>
|
||||
<span className="shrink-0">{localize('com_agents_contact')}:</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{contact?.email ? (
|
||||
<a href={`mailto:${contact.email}`} className="text-primary hover:underline">
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AgentDetailContentProps> = ({ 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 (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
{/* Agent avatar */}
|
||||
|
|
@ -149,12 +110,7 @@ const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
|
|||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Contact info */}
|
||||
{agent?.support_contact && formatContact() && (
|
||||
<div className="mt-1 text-center text-sm text-text-secondary">
|
||||
{localize('com_agents_contact')}: {formatContact()}
|
||||
</div>
|
||||
)}
|
||||
<AgentContact agent={agent} className="mt-1 justify-center text-center text-sm" />
|
||||
|
||||
{/* Agent description */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||
|
|
|
|||
|
|
@ -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 <img src={src} alt={`${agent.name} avatar`} />;
|
||||
}
|
||||
return <svg className="lucide-feather" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<div data-testid="dialog-wrapper" data-open={open}>
|
||||
{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;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onClick: (e: any) => {
|
||||
(children as any).props?.onClick?.(e);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
},
|
||||
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
|
||||
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
};
|
||||
});
|
||||
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 (
|
||||
<div data-testid="dialog-wrapper" data-open={open}>
|
||||
{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;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onClick: (e: any) => {
|
||||
(children as any).props?.onClick?.(e);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
},
|
||||
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
|
||||
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
};
|
||||
},
|
||||
{ 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', () => {
|
|||
</Wrapper>,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
71
client/src/components/Agents/tests/AgentContact.spec.tsx
Normal file
71
client/src/components/Agents/tests/AgentContact.spec.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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(
|
||||
<AgentContact
|
||||
agent={
|
||||
{
|
||||
support_contact: { name: 'Support Team', email: 'support@example.com' },
|
||||
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentContact
|
||||
agent={
|
||||
{
|
||||
support_contact: undefined,
|
||||
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
|
||||
'href',
|
||||
'mailto:owner@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a plain name when no email is available', () => {
|
||||
render(<AgentContact agent={{ owner_contact: { name: 'Owner User' } } as any} />);
|
||||
|
||||
expect(screen.getByText('Owner User')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no-contact text when no contact is available', () => {
|
||||
render(<AgentContact agent={{ support_contact: {}, owner_contact: undefined } as any} />);
|
||||
|
||||
expect(screen.getByText('No contact available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
135
client/src/components/Agents/tests/AgentDetailContent.spec.tsx
Normal file
135
client/src/components/Agents/tests/AgentDetailContent.spec.tsx
Normal file
|
|
@ -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 }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
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<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
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: () => <div data-testid="agent-avatar" />,
|
||||
}));
|
||||
|
||||
const renderWithClient = (children: React.ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
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(
|
||||
<AgentDetailContent
|
||||
agent={
|
||||
{
|
||||
...baseAgent,
|
||||
support_contact: { name: 'Support Team', email: 'support@example.com' },
|
||||
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentDetailContent
|
||||
agent={
|
||||
{
|
||||
...baseAgent,
|
||||
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
|
||||
'href',
|
||||
'mailto:owner@example.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
</div>
|
||||
))}
|
||||
{selectedAgent && (
|
||||
<AgentContact
|
||||
agent={selectedAgent}
|
||||
className="animate-fadeIn mt-2 max-w-md justify-center text-center text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null = null;
|
||||
let mockAgentsMap: Record<string, any> | undefined;
|
||||
let mockAssistantMap: Record<string, any> | 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: () => <span data-testid="birthday-icon" />,
|
||||
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
SplitText: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}),
|
||||
{ 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<string, string> = {
|
||||
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<string, any>;
|
||||
assistantMap?: Record<string, any>;
|
||||
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', () => () => <span data-testid="convo-icon" />);
|
||||
|
||||
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(<Landing centerFormOnLanding={false} />);
|
||||
|
||||
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(<Landing centerFormOnLanding={false} />);
|
||||
|
||||
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(<Landing centerFormOnLanding={false} />);
|
||||
|
||||
expect(screen.getByText('Assistant')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
60
packages/api/src/agents/contact.spec.ts
Normal file
60
packages/api/src/agents/contact.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
55
packages/api/src/agents/contact.ts
Normal file
55
packages/api/src/agents/contact.ts
Normal file
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue