diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml index 9fb2fd683..8f773532d 100644 --- a/.github/workflows/i18n-unused-keys.yml +++ b/.github/workflows/i18n-unused-keys.yml @@ -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 diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 3e7a877ad..ad197b756 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -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); /** diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 3035a37b1..80fdf593c 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -225,7 +225,8 @@ export type AgentPanelContextType = { setActivePanel: React.Dispatch>; setCurrentAgentId: React.Dispatch>; agent_id?: string; - agentsConfig?: t.TAgentsEndpoint; + agentsConfig?: t.TAgentsEndpoint | null; + endpointsConfig?: t.TEndpointsConfig | null; }; export type AgentModelPanelProps = { diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index fe36f3bf7..29b85c5de 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -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 = ({ 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 (
= ({ agent, onClick, className = '' }) {/* Category tag */} {agent.category && (
- +
)}
diff --git a/client/src/components/Agents/AgentCategoryDisplay.tsx b/client/src/components/Agents/AgentCategoryDisplay.tsx deleted file mode 100644 index 8ee6b2792..000000000 --- a/client/src/components/Agents/AgentCategoryDisplay.tsx +++ /dev/null @@ -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 = ({ - 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/Agents/CategoryTabs.tsx b/client/src/components/Agents/CategoryTabs.tsx index e7ac1efd4..85a7c2ac0 100644 --- a/client/src/components/Agents/CategoryTabs.tsx +++ b/client/src/components/Agents/CategoryTabs.tsx @@ -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 = ({ 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 = ({ 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 */} diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 3e73df636..8b7afc095 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 || '', }; } diff --git a/client/src/components/Agents/tests/Accessibility.spec.tsx b/client/src/components/Agents/tests/Accessibility.spec.tsx index e70ad3f3a..971849776 100644 --- a/client/src/components/Agents/tests/Accessibility.spec.tsx +++ b/client/src/components/Agents/tests/Accessibility.spec.tsx @@ -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'); diff --git a/client/src/components/Agents/tests/AgentCard.spec.tsx b/client/src/components/Agents/tests/AgentCard.spec.tsx index 35aa3e7e3..8bcf7fb1d 100644 --- a/client/src/components/Agents/tests/AgentCard.spec.tsx +++ b/client/src/components/Agents/tests/AgentCard.spec.tsx @@ -8,10 +8,42 @@ import type t from 'librechat-data-provider'; jest.mock('~/hooks/useLocalize', () => () => (key: string) => { const mockTranslations: Record = { 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) => { + const mockTranslations: Record = { + 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(); + + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('displays custom category label', () => { + const agentWithCustomCategory = { + ...mockAgent, + category: 'custom', + }; + + render(); + + expect(screen.getByText('Custom Category')).toBeInTheDocument(); + }); + + it('displays capitalized fallback for unknown category', () => { + const agentWithUnknownCategory = { + ...mockAgent, + category: 'unknown', + }; + + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('does not display category tag when category is not provided', () => { + render(); + + expect(screen.queryByText('General')).not.toBeInTheDocument(); + expect(screen.queryByText('Unknown')).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/Agents/tests/AgentCategoryDisplay.spec.tsx b/client/src/components/Agents/tests/AgentCategoryDisplay.spec.tsx deleted file mode 100644 index 9687a1c23..000000000 --- a/client/src/components/Agents/tests/AgentCategoryDisplay.spec.tsx +++ /dev/null @@ -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: {''}, - className: 'w-full', - }, - { - value: 'hr', - label: 'HR', - icon: {''}, - className: 'w-full', - }, - { - value: 'rd', - label: 'R&D', - icon: {''}, - className: 'w-full', - }, - { - value: 'finance', - label: 'Finance', - 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'); - }); -}); diff --git a/client/src/components/Agents/tests/CategoryTabs.spec.tsx b/client/src/components/Agents/tests/CategoryTabs.spec.tsx index be8cb8423..a38c16dde 100644 --- a/client/src/components/Agents/tests/CategoryTabs.spec.tsx +++ b/client/src/components/Agents/tests/CategoryTabs.spec.tsx @@ -9,7 +9,8 @@ import type t from 'librechat-data-provider'; jest.mock('~/hooks/useLocalize', () => () => (key: string) => { const mockTranslations: Record = { 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', diff --git a/client/src/components/Bookmarks/BookmarkEditDialog.tsx b/client/src/components/Bookmarks/BookmarkEditDialog.tsx index 7c0b494d7..aaf965c05 100644 --- a/client/src/components/Bookmarks/BookmarkEditDialog.tsx +++ b/client/src/components/Bookmarks/BookmarkEditDialog.tsx @@ -89,7 +89,7 @@ const BookmarkEditDialog = ({ {children} ({ + mode: 'onBlur', + reValidateMode: 'onChange', defaultValues: { tag: bookmark?.tag ?? '', description: bookmark?.description ?? '', @@ -98,23 +100,30 @@ const BookmarkForm = ({ { 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 && {errors.tag.message}} @@ -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" diff --git a/client/src/components/Prompts/EmptyPromptPreview.tsx b/client/src/components/Prompts/EmptyPromptPreview.tsx index b812d008a..79bc20e16 100644 --- a/client/src/components/Prompts/EmptyPromptPreview.tsx +++ b/client/src/components/Prompts/EmptyPromptPreview.tsx @@ -1,9 +1,12 @@ import React from 'react'; +import { useLocalize } from '~/hooks'; export default function EmptyPromptPreview() { + const localize = useLocalize(); + return ( -
- Select or Create a Prompt +
+ {localize('com_ui_select_or_create_prompt')}
); } diff --git a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx index 6fb46121e..5840fe0f1 100644 --- a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx +++ b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx @@ -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 ( (() => { - 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[]), diff --git a/client/src/hooks/Conversations/useUpdateTagsInConvo.ts b/client/src/hooks/Conversations/useUpdateTagsInConvo.ts index 815278bcf..4e545c86f 100644 --- a/client/src/hooks/Conversations/useUpdateTagsInConvo.ts +++ b/client/src/hooks/Conversations/useUpdateTagsInConvo.ts @@ -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; - 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; + 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>( + [QueryKeys.allConversations], + newData, + ); } - queryClient.setQueryData>( - [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++) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 514962af2..013d5cfb8 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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.", diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 55bc376d8..bc69f6ca8 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -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 = { diff --git a/packages/data-schemas/src/methods/agentCategory.ts b/packages/data-schemas/src/methods/agentCategory.ts index fb1b1aaa1..2dd467807 100644 --- a/packages/data-schemas/src/methods/agentCategory.ts +++ b/packages/data-schemas/src/methods/agentCategory.ts @@ -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 { + ): Promise { const AgentCategory = mongoose.models.AgentCategory as Model; 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 - 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 - true if categories were created/updated, false if no changes */ async function ensureDefaultCategories(): Promise { - const existingCategories = await getAllCategories(); - - if (existingCategories.length > 0) { - return false; // Categories already exist - } + const AgentCategory = mongoose.models.AgentCategory as Model; 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 { diff --git a/packages/data-schemas/src/schema/agentCategory.ts b/packages/data-schemas/src/schema/agentCategory.ts index 61792de3f..baf714191 100644 --- a/packages/data-schemas/src/schema/agentCategory.ts +++ b/packages/data-schemas/src/schema/agentCategory.ts @@ -1,4 +1,4 @@ -import { Schema, Document } from 'mongoose'; +import { Schema } from 'mongoose'; import type { IAgentCategory } from '~/types'; const agentCategorySchema = new Schema( @@ -31,6 +31,10 @@ const agentCategorySchema = new Schema( default: true, index: true, }, + custom: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/types/agentCategory.ts b/packages/data-schemas/src/types/agentCategory.ts index ccf266a61..1a814d289 100644 --- a/packages/data-schemas/src/types/agentCategory.ts +++ b/packages/data-schemas/src/types/agentCategory.ts @@ -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 &