🏷️ chore: Add Missing Localizations for Agents, Categories, Bookmarks (#9266)

* fix: error when updating bookmarks if no query data

* feat: localize bookmark dialog, form labels and validation messages, also improve validation

* feat: add localization for EmptyPromptPreview component and update translation.json

* chore: add missing localizations for static UI text

* chore: update AgentPanelContextType and useGetAgentsConfig to support null configurations

* refactor: update agent categories to support localization and custom properties, improve related typing

* ci: add localization for 'All' category and update tab names in accessibility tests

* chore: remove unused AgentCategoryDisplay component and its tests

* chore: add localization handling for agent category selector

* chore: enhance AgentCard to support localized category labels and add related tests

* chore: enhance i18n unused keys detection to include additional source directories and improve handling for agent category keys
This commit is contained in:
Danny Avila 2025-08-25 13:54:13 -04:00 committed by GitHub
parent 94426a3cae
commit bbfe4002eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 328 additions and 252 deletions

View file

@ -1,5 +1,10 @@
name: Detect Unused i18next Strings
# This workflow checks for unused i18n keys in translation files.
# It has special handling for:
# - com_ui_special_var_* keys that are dynamically constructed
# - com_agents_category_* keys that are stored in the database and used dynamically
on:
pull_request:
paths:
@ -7,6 +12,7 @@ on:
- "api/**"
- "packages/data-provider/src/**"
- "packages/client/**"
- "packages/data-schemas/src/**"
jobs:
detect-unused-i18n-keys:
@ -24,7 +30,7 @@ jobs:
# Define paths
I18N_FILE="client/src/locales/en/translation.json"
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client")
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
# Check if translation file exists
if [[ ! -f "$I18N_FILE" ]]; then
@ -52,6 +58,31 @@ jobs:
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
fi
# Special case for agent category keys that are dynamically used from database
elif [[ "$KEY" == com_agents_category_* ]]; then
# Check if agent category localization is being used
for DIR in "${SOURCE_DIRS[@]}"; do
# Check for dynamic category label/description usage
if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
# Check for the method that defines these keys
grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
# Check for direct usage in agentCategory.ts
grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
FOUND=true
break
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do

View file

@ -46,7 +46,7 @@ router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
* @route GET /agents/categories
*/
router.get('/categories', v1.getAgentCategories);
/**

View file

@ -225,7 +225,8 @@ export type AgentPanelContextType = {
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string;
agentsConfig?: t.TAgentsEndpoint;
agentsConfig?: t.TAgentsEndpoint | null;
endpointsConfig?: t.TEndpointsConfig | null;
};
export type AgentModelPanelProps = {

View file

@ -1,8 +1,8 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Label } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import { useLocalize } from '~/hooks';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
@ -15,6 +15,21 @@ interface AgentCardProps {
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
const { categories } = useAgentCategories();
const categoryLabel = useMemo(() => {
if (!agent.category) return '';
const category = categories.find((cat) => cat.value === agent.category);
if (category) {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
}
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
return (
<div
@ -49,9 +64,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
</Label>
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
</div>
)}
</div>

View file

@ -1,62 +0,0 @@
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<AgentCategoryDisplayProps> = ({
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 (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

View file

@ -1,8 +1,8 @@
import React from 'react';
import type t from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys } from '~/hooks';
import { SmartLoader } from './SmartLoader';
import { useLocalize } from '~/hooks/';
import { cn } from '~/utils';
/**
@ -36,14 +36,17 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
// Helper function to get category display name from database data
/** Helper function to get category display name from database data */
const getCategoryDisplayName = (category: t.TCategory) => {
// Special cases for system categories
if (category.value === 'promoted') {
return localize('com_agents_top_picks');
}
if (category.value === 'all') {
return 'All';
return localize('com_agents_all_category');
}
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
// Use database label or fallback to capitalized value
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
@ -158,7 +161,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}
tabIndex={activeTab === category.value ? 0 : -1}
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
aria-label={localize('com_agents_category_tab_label', {
category: getCategoryDisplayName(category),
position: index + 1,
total: categories.length,
})}
>
{getCategoryDisplayName(category)}
{/* Underline for active tab */}

View file

@ -7,8 +7,8 @@ import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/cl
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
@ -381,8 +381,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (displayCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@ -392,8 +392,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
: categoryData.description || '',
};
}
@ -455,8 +459,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (nextCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@ -466,8 +470,16 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(
categoryData.description as Parameters<
typeof localize
>[0],
)
: categoryData.description || '',
};
}

View file

@ -61,6 +61,7 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
com_agents_search_empty_heading: 'No search results',
com_agents_created_by: 'by',
com_agents_top_picks: 'Top Picks',
com_agents_all_category: 'All',
// ErrorDisplay translations
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
com_agents_error_network_title: 'Network Error',
@ -199,7 +200,7 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
@ -226,8 +227,8 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const allTab = screen.getByRole('tab', { name: /All tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
const allTab = screen.getByRole('tab', { name: /All category/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');

View file

@ -8,10 +8,42 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
return mockTranslations[key] || key;
});
// Mock useAgentCategories hook
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<string, string>) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
let translation = mockTranslations[key] || key;
// Replace placeholders with actual values
if (values) {
Object.entries(values).forEach(([placeholder, value]) => {
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
});
}
return translation;
},
useAgentCategories: () => ({
categories: [
{ value: 'general', label: 'com_agents_category_general' },
{ value: 'hr', label: 'com_agents_category_hr' },
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
],
}),
}));
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
@ -200,6 +232,49 @@ describe('AgentCard', () => {
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
expect(card).toHaveAttribute(
'aria-label',
'Test Agent agent. A test agent for testing purposes',
);
});
it('displays localized category label', () => {
const agentWithCategory = {
...mockAgent,
category: 'general',
};
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('displays custom category label', () => {
const agentWithCustomCategory = {
...mockAgent,
category: 'custom',
};
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
expect(screen.getByText('Custom Category')).toBeInTheDocument();
});
it('displays capitalized fallback for unknown category', () => {
const agentWithUnknownCategory = {
...mockAgent,
category: 'unknown',
};
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not display category tag when category is not provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
expect(screen.queryByText('General')).not.toBeInTheDocument();
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
});
});

View file

@ -1,90 +0,0 @@
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: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View file

@ -9,7 +9,8 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_all: 'All Agents',
com_agents_all_category: 'All',
com_ui_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',

View file

@ -89,7 +89,7 @@ const BookmarkEditDialog = ({
<OGDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title="Bookmark"
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
showCloseButton={false}
className="w-11/12 md:max-w-2xl"
main={

View file

@ -38,6 +38,8 @@ const BookmarkForm = ({
control,
formState: { errors },
} = useForm<TConversationTagRequest>({
mode: 'onBlur',
reValidateMode: 'onChange',
defaultValues: {
tag: bookmark?.tag ?? '',
description: bookmark?.description ?? '',
@ -98,23 +100,30 @@ const BookmarkForm = ({
<Input
type="text"
id="bookmark-tag"
aria-label="Bookmark"
aria-label={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
{...register('tag', {
required: 'tag is required',
required: localize('com_ui_field_required'),
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_title'),
length: 128,
}),
},
validate: (value) => {
return (
value === bookmark?.tag ||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
'tag must be unique'
localize('com_ui_bookmarks_tag_exists')
);
},
})}
aria-invalid={!!errors.tag}
placeholder="Bookmark"
placeholder={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
/>
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
</div>
@ -127,7 +136,10 @@ const BookmarkForm = ({
{...register('description', {
maxLength: {
value: 1048,
message: 'Maximum 1048 characters',
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_description'),
length: 1048,
}),
},
})}
id="bookmark-description"

View file

@ -1,9 +1,12 @@
import React from 'react';
import { useLocalize } from '~/hooks';
export default function EmptyPromptPreview() {
const localize = useLocalize();
return (
<div className="h-full w-full content-center text-center font-bold dark:text-gray-200">
Select or Create a Prompt
<div className="h-full w-full content-center text-center font-bold text-text-secondary">
{localize('com_ui_select_or_create_prompt')}
</div>
);
}

View file

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ControlCombobox } from '@librechat/client';
import {
useWatch,
@ -9,7 +8,7 @@ import {
useFormContext,
ControllerRenderProps,
} from 'react-hook-form';
import { useAgentCategories } from '~/hooks/Agents';
import { TranslationKeys, useLocalize, useAgentCategories } from '~/hooks';
import { cn } from '~/utils';
/**
@ -35,22 +34,25 @@ const useCategorySync = (agent_id: string | null) => {
* A component for selecting agent categories with form validation
*/
const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation();
const localize = useLocalize();
const formContext = useFormContext();
const { categories } = useAgentCategories();
// Always call useWatch
const agent_id = useWatch({
name: 'id',
control: formContext.control,
});
// Use custom hook for category sync
const { syncCategory } = useCategorySync(agent_id);
const getCategoryLabel = (category: { label: string; value: string }) => {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
};
// Transform categories to the format expected by ControlCombobox
const comboboxItems = categories.map((category) => ({
label: category.label,
label: getCategoryLabel(category),
value: category.value,
}));
@ -59,8 +61,8 @@ const AgentCategorySelector: React.FC<{ className?: string }> = ({ className })
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
};
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
const searchPlaceholder = localize('com_ui_search_agent_category');
const ariaLabel = localize('com_ui_agent_category_selector_aria');
return (
<Controller

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEndpointsConfig, TConfig } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEndpointsConfig } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
interface UseGetAgentsConfigOptions {
@ -20,11 +20,12 @@ export default function useGetAgentsConfig(options?: UseGetAgentsConfigOptions):
const endpointsConfig = providedConfig || queriedConfig;
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
const config: TAgentsEndpoint | null =
(endpointsConfig?.[EModelEndpoint.agents] as TAgentsEndpoint | null) ?? null;
if (!config) return null;
return {
...(config as TConfig),
...config,
capabilities: Array.isArray(config.capabilities)
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
: ([] as AgentCapabilities[]),

View file

@ -3,7 +3,6 @@ import { QueryKeys } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { updateConvoFieldsInfinite } from '~/utils/convos';
const useUpdateTagsInConvo = () => {
const queryClient = useQueryClient();
@ -53,30 +52,31 @@ const useUpdateTagsInConvo = () => {
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (
conversation.conversationId &&
'tags' in conversation &&
Array.isArray((conversation as { tags?: string[] }).tags) &&
(conversation as { tags?: string[] }).tags?.includes(tag)
) {
(conversation as { tags: string[] }).tags = (conversation as { tags: string[] }).tags.map(
(t: string) => (t === tag ? newTag : t),
);
}
return conversation;
});
if (data) {
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (
conversation.conversationId &&
'tags' in conversation &&
Array.isArray((conversation as { tags?: string[] }).tags) &&
(conversation as { tags?: string[] }).tags?.includes(tag)
) {
(conversation as { tags: string[] }).tags = (
conversation as { tags: string[] }
).tags.map((t: string) => (t === tag ? newTag : t));
}
return conversation;
});
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
const conversationIdsWithTag = [] as string[];
// update the tag to newTag from the cache of each conversation
for (let i = 0; i < conversationIdsWithTag.length; i++) {

View file

@ -6,8 +6,24 @@
"com_a11y_start": "The AI has started their reply.",
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
"com_agents_all": "All Agents",
"com_agents_all_category": "All",
"com_agents_all_description": "Browse all shared agents across all categories",
"com_agents_by_librechat": "by LibreChat",
"com_agents_category_aftersales": "After Sales",
"com_agents_category_aftersales_description": "Agents specialized in post-sale support, maintenance, and customer service",
"com_agents_category_empty": "No agents found in the {{category}} category",
"com_agents_category_finance": "Finance",
"com_agents_category_finance_description": "Agents specialized in financial analysis, budgeting, and accounting",
"com_agents_category_general": "General",
"com_agents_category_general_description": "General purpose agents for common tasks and inquiries",
"com_agents_category_hr": "Human Resources",
"com_agents_category_hr_description": "Agents specialized in HR processes, policies, and employee support",
"com_agents_category_it": "IT",
"com_agents_category_it_description": "Agents for IT support, technical troubleshooting, and system administration",
"com_agents_category_rd": "Research & Development",
"com_agents_category_rd_description": "Agents focused on R&D processes, innovation, and technical research",
"com_agents_category_sales": "Sales",
"com_agents_category_sales_description": "Agents focused on sales processes, customer relations",
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
"com_agents_category_tabs_label": "Agent Categories",
"com_agents_clear_search": "Clear search",
@ -724,6 +740,7 @@
"com_ui_bookmarks_edit": "Edit Bookmark",
"com_ui_bookmarks_filter": "Filter bookmarks...",
"com_ui_bookmarks_new": "New Bookmark",
"com_ui_bookmarks_tag_exists": "A bookmark with this title already exists",
"com_ui_bookmarks_title": "Title",
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
@ -868,6 +885,7 @@
"com_ui_feedback_tag_not_matched": "Didn't match my request",
"com_ui_feedback_tag_other": "Other issue",
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
"com_ui_field_required": "This field is required",
"com_ui_file_size": "File Size",
"com_ui_files": "Files",
@ -1118,6 +1136,7 @@
"com_ui_select_search_plugin": "Search plugin by name",
"com_ui_select_search_provider": "Search provider by name",
"com_ui_select_search_region": "Search region by name",
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
"com_ui_set": "Set",
"com_ui_share": "Share",
"com_ui_share_create_message": "Your name and any messages you add after sharing stay private.",

View file

@ -164,11 +164,12 @@ export type TCategory = {
id?: string;
value: string;
label: string;
description?: string;
custom?: boolean;
};
export type TMarketplaceCategory = TCategory & {
count: number;
description?: string;
};
export type TError = {

View file

@ -1,5 +1,5 @@
import type { Model, Types, DeleteResult } from 'mongoose';
import type { IAgentCategory, AgentCategory } from '../types/agentCategory';
import type { Model, Types } from 'mongoose';
import type { IAgentCategory } from '~/types';
export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) {
/**
@ -52,8 +52,9 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose'))
label?: string;
description?: string;
order?: number;
custom?: boolean;
}>,
): Promise<any> {
): Promise<import('mongoose').mongo.BulkWriteResult> {
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const operations = categories.map((category, index) => ({
@ -66,6 +67,7 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose'))
description: category.description || '',
order: category.order || index,
isActive: true,
custom: category.custom || false,
},
},
upsert: true,
@ -145,63 +147,104 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose'))
}
/**
* Ensure default categories exist, seed them if none are present
* @returns Promise<boolean> - true if categories were seeded, false if they already existed
* Ensure default categories exist and update them if they don't have localization keys
* @returns Promise<boolean> - true if categories were created/updated, false if no changes
*/
async function ensureDefaultCategories(): Promise<boolean> {
const existingCategories = await getAllCategories();
if (existingCategories.length > 0) {
return false; // Categories already exist
}
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const defaultCategories = [
{
value: 'general',
label: 'General',
description: 'General purpose agents for common tasks and inquiries',
label: 'com_agents_category_general',
description: 'com_agents_category_general_description',
order: 0,
},
{
value: 'hr',
label: 'Human Resources',
description: 'Agents specialized in HR processes, policies, and employee support',
label: 'com_agents_category_hr',
description: 'com_agents_category_hr_description',
order: 1,
},
{
value: 'rd',
label: 'Research & Development',
description: 'Agents focused on R&D processes, innovation, and technical research',
label: 'com_agents_category_rd',
description: 'com_agents_category_rd_description',
order: 2,
},
{
value: 'finance',
label: 'Finance',
description: 'Agents specialized in financial analysis, budgeting, and accounting',
label: 'com_agents_category_finance',
description: 'com_agents_category_finance_description',
order: 3,
},
{
value: 'it',
label: 'IT',
description: 'Agents for IT support, technical troubleshooting, and system administration',
label: 'com_agents_category_it',
description: 'com_agents_category_it_description',
order: 4,
},
{
value: 'sales',
label: 'Sales',
description: 'Agents focused on sales processes, customer relations.',
label: 'com_agents_category_sales',
description: 'com_agents_category_sales_description',
order: 5,
},
{
value: 'aftersales',
label: 'After Sales',
description: 'Agents specialized in post-sale support, maintenance, and customer service',
label: 'com_agents_category_aftersales',
description: 'com_agents_category_aftersales_description',
order: 6,
},
];
await seedCategories(defaultCategories);
return true; // Categories were seeded
const existingCategories = await getAllCategories();
const existingCategoryMap = new Map(existingCategories.map((cat) => [cat.value, cat]));
const updates = [];
let created = 0;
for (const defaultCategory of defaultCategories) {
const existingCategory = existingCategoryMap.get(defaultCategory.value);
if (existingCategory) {
const isNotCustom = !existingCategory.custom;
const needsLocalization = !existingCategory.label.startsWith('com_');
if (isNotCustom && needsLocalization) {
updates.push({
value: defaultCategory.value,
label: defaultCategory.label,
description: defaultCategory.description,
});
}
} else {
await createCategory({
...defaultCategory,
isActive: true,
custom: false,
});
created++;
}
}
if (updates.length > 0) {
const bulkOps = updates.map((update) => ({
updateOne: {
filter: { value: update.value, custom: { $ne: true } },
update: {
$set: {
label: update.label,
description: update.description,
},
},
},
}));
await AgentCategory.bulkWrite(bulkOps, { ordered: false });
}
return updates.length > 0 || created > 0;
}
return {

View file

@ -1,4 +1,4 @@
import { Schema, Document } from 'mongoose';
import { Schema } from 'mongoose';
import type { IAgentCategory } from '~/types';
const agentCategorySchema = new Schema<IAgentCategory>(
@ -31,6 +31,10 @@ const agentCategorySchema = new Schema<IAgentCategory>(
default: true,
index: true,
},
custom: {
type: Boolean,
default: false,
},
},
{
timestamps: true,

View file

@ -11,6 +11,8 @@ export type AgentCategory = {
order: number;
/** Whether the category is active and should be displayed */
isActive: boolean;
/** Whether this is a custom user-created category */
custom?: boolean;
};
export type IAgentCategory = AgentCategory &