mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🏷️ 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:
parent
94426a3cae
commit
bbfe4002eb
22 changed files with 328 additions and 252 deletions
33
.github/workflows/i18n-unused-keys.yml
vendored
33
.github/workflows/i18n-unused-keys.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
/**
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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 */}
|
||||
|
|
|
@ -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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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[]),
|
||||
|
|
|
@ -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++) {
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 &
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue