From c43a52b4c9326d002f7b11f450255e126faae1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPraneeth?= Date: Thu, 22 May 2025 11:57:03 +0200 Subject: [PATCH] feat: Add category and support contact fields to Agent schema and UI components --- api/models/Agent.js | 63 ++++++++++++- client/src/common/agents-types.ts | 6 ++ .../SidePanel/Agents/AgentCategoryDisplay.tsx | 62 ++++++++++++ .../Agents/AgentCategorySelector.tsx | 94 +++++++++++++++++++ .../SidePanel/Agents/AgentConfig.tsx | 91 ++++++++++++++++++ .../SidePanel/Agents/AgentPanel.tsx | 8 +- .../SidePanel/Agents/AgentSelect.tsx | 4 + .../__tests__/AgentCategoryDisplay.spec.tsx | 90 ++++++++++++++++++ client/src/constants/agentCategories.ts | 48 ++++++++++ .../__tests__/useAgentCategories.spec.tsx | 37 ++++++++ client/src/hooks/Agents/index.ts | 2 + .../src/hooks/Agents/useAgentCategories.tsx | 44 +++++++++ client/src/locales/en/translation.json | 31 ++++-- packages/data-provider/src/schemas.ts | 5 + packages/data-schemas/src/schema/agent.ts | 7 ++ 15 files changed, 581 insertions(+), 11 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/AgentCategoryDisplay.tsx create mode 100644 client/src/components/SidePanel/Agents/AgentCategorySelector.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/AgentCategoryDisplay.spec.tsx create mode 100644 client/src/constants/agentCategories.ts create mode 100644 client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx create mode 100644 client/src/hooks/Agents/useAgentCategories.tsx diff --git a/api/models/Agent.js b/api/models/Agent.js index 9b34eeae6..07a742e37 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -12,6 +12,61 @@ const { } = require('./Project'); const getLogStores = require('~/cache/getLogStores'); +// Category values - must match the frontend values in client/src/constants/agentCategories.ts +const CATEGORY_VALUES = { + GENERAL: 'general', + HR: 'hr', + RD: 'rd', + FINANCE: 'finance', + IT: 'it', + SALES: 'sales', + AFTERSALES: 'aftersales', +}; + +const VALID_CATEGORIES = Object.values(CATEGORY_VALUES); + +// Add category field to the Agent schema if it doesn't already exist +if (!agentSchema.paths.category) { + agentSchema.add({ + category: { + type: String, + trim: true, + enum: { + values: VALID_CATEGORIES, + message: + '"{VALUE}" is not a supported agent category. Valid categories are: ' + + VALID_CATEGORIES.join(', ') + + '.', + }, + index: true, + default: CATEGORY_VALUES.GENERAL, + }, + }); +} + +// Add support_contact field to the Agent schema if it doesn't already exist +if (!agentSchema.paths.support_contact) { + agentSchema.add({ + support_contact: { + type: Object, + default: {}, + name: { + type: String, + minlength: [3, 'Support contact name must be at least 3 characters.'], + trim: true, + }, + email: { + type: String, + match: [ + /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + 'Please enter a valid email address.', + ], + trim: true, + }, + }, + }); +} + const Agent = mongoose.model('agent', agentSchema); /** @@ -21,7 +76,12 @@ const Agent = mongoose.model('agent', agentSchema); * @throws {Error} If the agent creation fails. */ const createAgent = async (agentData) => { - return (await Agent.create(agentData)).toObject(); + // Ensure the agent has a category (default to 'general' if none provided) + const dataWithCategory = { + ...agentData, + category: agentData.category || 'general', + }; + return (await Agent.create(dataWithCategory)).toObject(); }; /** @@ -280,6 +340,7 @@ const getListAgents = async (searchParameter) => { projectIds: 1, description: 1, isCollaborative: 1, + category: 1, }).lean() ).map((agent) => { if (agent.author?.toString() !== author) { diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 982cbfdb1..993665c3a 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -16,6 +16,11 @@ export type TAgentCapabilities = { [AgentCapabilities.hide_sequential_outputs]?: boolean; }; +export type SupportContact = { + name?: string; + email?: string; +}; + export type AgentForm = { agent?: TAgentOption; id: string; @@ -29,4 +34,5 @@ export type AgentForm = { agent_ids?: string[]; [AgentCapabilities.artifacts]?: ArtifactModes | string; recursion_limit?: number; + support_contact?: SupportContact; } & TAgentCapabilities; diff --git a/client/src/components/SidePanel/Agents/AgentCategoryDisplay.tsx b/client/src/components/SidePanel/Agents/AgentCategoryDisplay.tsx new file mode 100644 index 000000000..8ee6b2792 --- /dev/null +++ b/client/src/components/SidePanel/Agents/AgentCategoryDisplay.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useAgentCategories } from '~/hooks/Agents'; +import { cn } from '~/utils'; + +interface AgentCategoryDisplayProps { + category?: string; + className?: string; + showIcon?: boolean; + iconClassName?: string; + showEmptyFallback?: boolean; +} + +/** + * Component to display an agent category with proper translation + * + * @param category - The category value (e.g., "general", "hr", etc.) + * @param className - Optional className for the container + * @param showIcon - Whether to show the category icon + * @param iconClassName - Optional className for the icon + * @param showEmptyFallback - Whether to show a fallback for empty categories + */ +const AgentCategoryDisplay: React.FC = ({ + category, + className = '', + showIcon = true, + iconClassName = 'h-4 w-4 mr-2', + showEmptyFallback = false, +}) => { + const { categories, emptyCategory } = useAgentCategories(); + + // Find the category in our processed categories list + const categoryItem = categories.find((c) => c.value === category); + + // Handle empty string case differently than undefined/null + if (category === '') { + if (!showEmptyFallback) { + return null; + } + // Show the empty category placeholder + return ( +
+ {emptyCategory.label} +
+ ); + } + + // No category or unknown category + if (!category || !categoryItem) { + return null; + } + + return ( +
+ {showIcon && categoryItem.icon && ( + {categoryItem.icon} + )} + {categoryItem.label} +
+ ); +}; + +export default AgentCategoryDisplay; diff --git a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx new file mode 100644 index 000000000..788aee2e5 --- /dev/null +++ b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx @@ -0,0 +1,94 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useFormContext, + Controller, + useWatch, + ControllerRenderProps, + FieldValues, + FieldPath, +} from 'react-hook-form'; +import ControlCombobox from '~/components/ui/ControlCombobox'; +import { useAgentCategories } from '~/hooks/Agents'; +import { OptionWithIcon } from '~/common/types'; +import { cn } from '~/utils'; + +/** + * A component for selecting agent categories with form validation + */ +const AgentCategorySelector: React.FC = () => { + const { t } = useTranslation(); + const formContext = useFormContext(); + const { categories } = useAgentCategories(); + + // Methods + const handleCategorySync = ( + field: ControllerRenderProps>, + agent_id: string | null, + ) => { + useEffect(() => { + if (agent_id === '') { + // Form has been reset, ensure field is set to default + if (field.value !== 'general') { + field.onChange('general'); + } + } + // If value is empty or undefined, default to 'general' + if (!field.value) { + field.onChange('general'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [field]); // Removing agent_id from dependencies as suggested by ESLint + }; + + const getCategoryDisplayValue = (value: string) => { + const categoryItem = comboboxItems.find((c) => c.value === value); + return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label; + }; + + // Always call useWatch + const agent_id = useWatch({ + name: 'id', + control: formContext.control, + }); + + // Transform categories to the format expected by ControlCombobox + const comboboxItems = categories.map((category) => ({ + label: category.label, + value: category.value, + })); + + const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...'); + const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector"); + + return ( + { + handleCategorySync(field, agent_id); + + const displayValue = getCategoryDisplayValue(field.value); + + return ( + { + field.onChange(value || 'general'); + }} + items={comboboxItems} + className="" + ariaLabel={ariaLabel} + isCollapsed={false} + showCarat={true} + /> + ); + }} + /> + ); +}; + +export default AgentCategorySelector; diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index aebb33fee..7ebc34bfa 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -18,6 +18,7 @@ import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; import AgentTool from './AgentTool'; import CodeForm from './Code/Form'; +import AgentCategorySelector from './AgentCategorySelector'; import { Panel } from '~/common'; const labelClass = 'mb-2 text-token-text-primary block font-medium'; @@ -228,6 +229,14 @@ export default function AgentConfig({ )} /> + + {/* Category */} +
+ + +
{/* Instructions */} {/* Model and Provider */} @@ -329,6 +338,88 @@ export default function AgentConfig({ + + {/* Support Contact (Optional) */} +
+
+ + + +
+
+ {/* Support Contact Name */} +
+ + ( + <> + + {error && ( + + {error.message} + + )} + + )} + /> +
+ + {/* Support Contact Email */} +
+ + ( + <> + + {error && ( + + {error.message} + + )} + + )} + /> +
+
+
-
+
{ diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentCategoryDisplay.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentCategoryDisplay.spec.tsx new file mode 100644 index 000000000..73ae07a2d --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/AgentCategoryDisplay.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AgentCategoryDisplay from '../AgentCategoryDisplay'; + +// Mock the useAgentCategories hook +jest.mock('~/hooks/Agents', () => ({ + useAgentCategories: () => ({ + categories: [ + { + value: 'general', + label: 'General', + icon: Icon, + className: 'w-full' + }, + { + value: 'hr', + label: 'HR', + icon: Icon, + className: 'w-full' + }, + { + value: 'rd', + label: 'R&D', + icon: Icon, + className: 'w-full' + }, + { + value: 'finance', + label: 'Finance', + icon: Icon, + className: 'w-full' + }, + ], + emptyCategory: { + value: '', + label: 'General', + className: 'w-full' + } + }) +})); + +describe('AgentCategoryDisplay', () => { + it('should display the proper label for a category', () => { + render(); + expect(screen.getByText('R&D')).toBeInTheDocument(); + }); + + it('should display the icon when showIcon is true', () => { + render(); + expect(screen.getByTestId('icon-finance')).toBeInTheDocument(); + expect(screen.getByText('Finance')).toBeInTheDocument(); + }); + + it('should not display the icon when showIcon is false', () => { + render(); + expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument(); + expect(screen.getByText('HR')).toBeInTheDocument(); + }); + + it('should apply custom classnames', () => { + render(); + expect(screen.getByText('General').parentElement).toHaveClass('test-class'); + }); + + it('should not render anything for unknown categories', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything when no category is provided', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything for empty category when showEmptyFallback is false', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should render empty category placeholder when showEmptyFallback is true', () => { + render(); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('should apply custom iconClassName to the icon', () => { + render(); + const iconElement = screen.getByTestId('icon-general').parentElement; + expect(iconElement).toHaveClass('custom-icon-class'); + }); +}); \ No newline at end of file diff --git a/client/src/constants/agentCategories.ts b/client/src/constants/agentCategories.ts new file mode 100644 index 000000000..242d70499 --- /dev/null +++ b/client/src/constants/agentCategories.ts @@ -0,0 +1,48 @@ +import { TranslationKeys } from '~/hooks/useLocalize'; + +export interface AgentCategory { + label: TranslationKeys; + value: string; +} + +// Category values - must match the backend values in Agent.js +export const CATEGORY_VALUES = { + GENERAL: 'general', + HR: 'hr', + RD: 'rd', + FINANCE: 'finance', + IT: 'it', + SALES: 'sales', + AFTERSALES: 'aftersales', +} as const; + +// Type for category values to ensure type safety +export type AgentCategoryValue = (typeof CATEGORY_VALUES)[keyof typeof CATEGORY_VALUES]; + +// Display labels for each category +const CATEGORY_LABELS = { + [CATEGORY_VALUES.GENERAL]: 'com_ui_agent_category_general', + [CATEGORY_VALUES.HR]: 'com_ui_agent_category_hr', + [CATEGORY_VALUES.RD]: 'com_ui_agent_category_rd', // R&D + [CATEGORY_VALUES.FINANCE]: 'com_ui_agent_category_finance', + [CATEGORY_VALUES.IT]: 'com_ui_agent_category_it', + [CATEGORY_VALUES.SALES]: 'com_ui_agent_category_sales', + [CATEGORY_VALUES.AFTERSALES]: 'com_ui_agent_category_aftersales', +} as const; + +// The categories array used in the UI +export const AGENT_CATEGORIES: AgentCategory[] = [ + { value: CATEGORY_VALUES.GENERAL, label: CATEGORY_LABELS[CATEGORY_VALUES.GENERAL] }, + { value: CATEGORY_VALUES.HR, label: CATEGORY_LABELS[CATEGORY_VALUES.HR] }, + { value: CATEGORY_VALUES.RD, label: CATEGORY_LABELS[CATEGORY_VALUES.RD] }, + { value: CATEGORY_VALUES.FINANCE, label: CATEGORY_LABELS[CATEGORY_VALUES.FINANCE] }, + { value: CATEGORY_VALUES.IT, label: CATEGORY_LABELS[CATEGORY_VALUES.IT] }, + { value: CATEGORY_VALUES.SALES, label: CATEGORY_LABELS[CATEGORY_VALUES.SALES] }, + { value: CATEGORY_VALUES.AFTERSALES, label: CATEGORY_LABELS[CATEGORY_VALUES.AFTERSALES] }, +]; + +// The empty category placeholder +export const EMPTY_AGENT_CATEGORY: AgentCategory = { + value: '', + label: 'com_ui_agent_category_general', +}; diff --git a/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx b/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx new file mode 100644 index 000000000..1b9cd9482 --- /dev/null +++ b/client/src/hooks/Agents/__tests__/useAgentCategories.spec.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useAgentCategories from '../useAgentCategories'; +import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; + +// Mock the useLocalize hook +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => (key: string) => { + // Simple mock implementation that returns the key as the translation + return key === 'com_ui_agent_category_general' + ? 'General (Translated)' + : key; + }, +})); + +describe('useAgentCategories', () => { + it('should return processed categories with correct structure', () => { + const { result } = renderHook(() => useAgentCategories()); + + // Check that we have the expected number of categories + expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length); + + // Check that the first category has the expected structure + const firstCategory = result.current.categories[0]; + const firstOriginalCategory = AGENT_CATEGORIES[0]; + + expect(firstCategory.value).toBe(firstOriginalCategory.value); + + // Check that labels are properly translated + expect(firstCategory.label).toBe('General (Translated)'); + expect(firstCategory.className).toBe('w-full'); + + // Check the empty category + expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value); + expect(result.current.emptyCategory.label).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index 08c0a6166..a4f970cd8 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -1,2 +1,4 @@ export { default as useAgentsMap } from './useAgentsMap'; export { default as useSelectAgent } from './useSelectAgent'; +export { default as useAgentCategories } from './useAgentCategories'; +export type { ProcessedAgentCategory } from './useAgentCategories'; diff --git a/client/src/hooks/Agents/useAgentCategories.tsx b/client/src/hooks/Agents/useAgentCategories.tsx new file mode 100644 index 000000000..305419cbe --- /dev/null +++ b/client/src/hooks/Agents/useAgentCategories.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import useLocalize from '~/hooks/useLocalize'; +import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories'; +import { ReactNode } from 'react'; + +// This interface matches the structure used by the ControlCombobox component +export interface ProcessedAgentCategory { + label: string; // Translated label + value: string; // Category value + className?: string; + icon?: ReactNode; // Optional icon for the category +} + +/** + * Custom hook that provides processed and translated agent categories + * + * @returns Object containing categories and emptyCategory + */ +const useAgentCategories = () => { + const localize = useLocalize(); + + const categories = useMemo((): ProcessedAgentCategory[] => { + return AGENT_CATEGORIES.map((category) => ({ + label: localize(category.label), + value: category.value, + className: 'w-full', + // Note: Icons for categories should be handled separately + // This fixes the interface but doesn't implement icons + })); + }, [localize]); + + const emptyCategory = useMemo( + (): ProcessedAgentCategory => ({ + label: localize(EMPTY_AGENT_CATEGORY.label), + value: EMPTY_AGENT_CATEGORY.value, + className: 'w-full', + }), + [localize], + ); + + return { categories, emptyCategory }; +}; + +export default useAgentCategories; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 827c5a26c..db745289b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -29,7 +29,7 @@ "com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's", "com_assistants_add_actions": "Add Actions", "com_assistants_add_tools": "Add Tools", - "com_assistants_allow_sites_you_trust": "Only allow sites you trust", + "com_assistants_allow_sites_you_trust": "Only allow sites you trust.", "com_assistants_append_date": "Append Current Date & Time", "com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.", "com_assistants_attempt_info": "Assistant wants to send the following:", @@ -523,6 +523,9 @@ "com_ui_backup_codes_regenerated": "Backup codes have been regenerated successfully", "com_ui_basic": "Basic", "com_ui_basic_auth_header": "Basic authorization header", + "com_ui_client_credential_flow": "Client Credential Flow", + "com_ui_auth_code_flow": "Authorization Code Flow", + "com_ui_oauth_flow": "OAuth Flow", "com_ui_bearer": "Bearer", "com_ui_bookmark_delete_confirm": "Are you sure you want to delete this bookmark?", "com_ui_bookmarks": "Bookmarks", @@ -546,6 +549,15 @@ "com_ui_callback_url": "Callback URL", "com_ui_cancel": "Cancel", "com_ui_category": "Category", + "com_ui_category_required_agent": "Category is required", + "com_ui_agent_category_selector_aria": "Agent's category selector", + "com_ui_agent_category_general": "General", + "com_ui_agent_category_hr": "HR", + "com_ui_agent_category_rd": "R&D", + "com_ui_agent_category_finance": "Finance", + "com_ui_agent_category_it": "IT", + "com_ui_agent_category_sales": "Sales", + "com_ui_agent_category_aftersales": "After Sales", "com_ui_chat": "Chat", "com_ui_chat_history": "Chat History", "com_ui_clear": "Clear", @@ -784,6 +796,7 @@ "com_ui_schema": "Schema", "com_ui_scope": "Scope", "com_ui_search": "Search", + "com_ui_search_agent_category": "Search categories...", "com_ui_secret_key": "Secret Key", "com_ui_select": "Select", "com_ui_select_file": "Select a file", @@ -826,6 +839,13 @@ "com_ui_stop": "Stop", "com_ui_storage": "Storage", "com_ui_submit": "Submit", + "com_ui_support_contact": "Support Contact", + "com_ui_support_contact_name": "Name", + "com_ui_support_contact_name_placeholder": "Support contact name", + "com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters", + "com_ui_support_contact_email": "Email", + "com_ui_support_contact_email_placeholder": "support@example.com", + "com_ui_support_contact_email_invalid": "Please enter a valid email address", "com_ui_teach_or_explain": "Learning", "com_ui_temporary": "Temporary Chat", "com_ui_terms_and_conditions": "Terms and Conditions", @@ -871,13 +891,6 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", - "com_ui_getting_started": "Getting Started", - "com_ui_creating_image": "Creating image. May take a moment", - "com_ui_adding_details": "Adding details", - "com_ui_final_touch": "Final touch", - "com_ui_image_created": "Image created", - "com_ui_edit_editing_image": "Editing image", - "com_ui_image_edited": "Image edited", "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." -} +} \ No newline at end of file diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index d7fc2d23d..3697703f3 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -177,6 +177,11 @@ export const defaultAgentFormValues = { recursion_limit: undefined, [Tools.execute_code]: false, [Tools.file_search]: false, + category: 'general', + support_contact: { + name: '', + email: '', + }, }; export const ImageVisionTool: FunctionTool = { diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 642d281c6..5e9231060 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -1,4 +1,9 @@ import { Schema, Document, Types } from 'mongoose'; +export interface ISupportContact { + name?: string; + email?: string; +} + export interface IAgent extends Omit { id: string; name?: string; @@ -26,6 +31,8 @@ export interface IAgent extends Omit { conversation_starters?: string[]; tool_resources?: unknown; projectIds?: Types.ObjectId[]; + category: string; + support_contact?: ISupportContact; } const agentSchema = new Schema(