diff --git a/client/src/components/Chat/Input/PopoverButtons.tsx b/client/src/components/Chat/Input/PopoverButtons.tsx index 0aa4d842d0..fa2211ecc6 100644 --- a/client/src/components/Chat/Input/PopoverButtons.tsx +++ b/client/src/components/Chat/Input/PopoverButtons.tsx @@ -26,7 +26,7 @@ export default function PopoverButtons({ buttonClass?: string; iconClass?: string; endpoint?: EModelEndpoint | string; - endpointType?: EModelEndpoint | string; + endpointType?: EModelEndpoint | string | null; model?: string | null; }) { const { diff --git a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx index 0978fe462a..9a723d94fa 100644 --- a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx +++ b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx @@ -10,10 +10,16 @@ import { mapEndpoints, getConvoSwitchLogic, } from '~/utils'; -import { Input, Label, SelectDropDown, Dialog, DialogClose, DialogButton } from '~/components'; +import { + Input, + Label, + OGDialog, + OGDialogTitle, + SelectDropDown, + OGDialogContent, +} from '~/components'; import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks'; import PopoverButtons from '~/components/Chat/Input/PopoverButtons'; -import DialogTemplate from '~/components/ui/DialogTemplate'; import { EndpointSettings } from '~/components/Endpoints'; import { useGetEndpointsQuery } from '~/data-provider'; import { useChatContext } from '~/Providers'; @@ -117,111 +123,107 @@ const EditPresetDialog = ({ [queryClient, setOptions], ); + const handleOpenChange = (open: boolean) => { + setPresetModalVisible(open); + if (!open) { + setPreset(null); + } + }; + const { endpoint: _endpoint, endpointType, model } = preset || {}; const endpoint = _endpoint ?? ''; + if (!endpoint) { return null; - } else if (isAgentsEndpoint(endpoint)) { + } + + if (isAgentsEndpoint(endpoint)) { return null; } return ( - { - setPresetModalVisible(open); - if (!open) { - setPreset(null); - } - }} - > - -
-
-
- - -
-
- - -
-
-
-
- - -
-
+ + + + {`${localize('com_ui_edit')} ${localize('com_endpoint_preset')} - ${preset?.title}`} + + +
+ {/* Header section with preset name and endpoint */} +
+
+ +
-
-
- + +
- } - buttons={ -
- + +
+ + {/* Separator */} +
+ + {/* Settings section */} +
+ +
+ + {/* Action buttons */} +
+
- } - footerClassName="bg-white dark:bg-gray-700" - /> -
+ + + ); }; diff --git a/client/src/hooks/Input/useMentions.ts b/client/src/hooks/Input/useMentions.ts index 3fafd7a2de..46f438ba91 100644 --- a/client/src/hooks/Input/useMentions.ts +++ b/client/src/hooks/Input/useMentions.ts @@ -34,20 +34,20 @@ const assistantMapFn = assistantMap: TAssistantsMap; endpointsConfig: TEndpointsConfig; }) => - ({ id, name, description }) => ({ - type: endpoint, - label: name ?? '', - value: id, - description: description ?? '', - icon: EndpointIcon({ - conversation: { assistant_id: id, endpoint }, - containerClassName: 'shadow-stroke overflow-hidden rounded-full', - endpointsConfig: endpointsConfig, - context: 'menu-item', - assistantMap, - size: 20, - }), - }); + ({ id, name, description }) => ({ + type: endpoint, + label: name ?? '', + value: id, + description: description ?? '', + icon: EndpointIcon({ + conversation: { assistant_id: id, endpoint }, + containerClassName: 'shadow-stroke overflow-hidden rounded-full', + endpointsConfig: endpointsConfig, + context: 'menu-item', + assistantMap, + size: 20, + }), + }); export default function useMentions({ assistantMap, @@ -226,7 +226,7 @@ export default function useMentions({ assistantListMap, includeAssistants, interfaceConfig.presets, - interfaceConfig.endpointsMenu, + interfaceConfig.modelSelect, ]); return { diff --git a/client/src/utils/__tests__/cleanupPreset.test.ts b/client/src/utils/__tests__/cleanupPreset.test.ts new file mode 100644 index 0000000000..a03477de15 --- /dev/null +++ b/client/src/utils/__tests__/cleanupPreset.test.ts @@ -0,0 +1,224 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import cleanupPreset from '../cleanupPreset'; +import type { TPreset } from 'librechat-data-provider'; + +// Mock parseConvo since we're focusing on testing the chatGptLabel migration logic +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + parseConvo: jest.fn((input) => { + // Return a simplified mock that passes through most properties + const { conversation } = input; + return { + ...conversation, + model: conversation?.model || 'gpt-3.5-turbo', + }; + }), +})); + +describe('cleanupPreset', () => { + const basePreset = { + presetId: 'test-preset-id', + title: 'Test Preset', + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + temperature: 0.7, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('chatGptLabel migration', () => { + it('should migrate chatGptLabel to modelLabel when only chatGptLabel exists', () => { + const preset = { + ...basePreset, + chatGptLabel: 'Custom ChatGPT Label', + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('Custom ChatGPT Label'); + expect(result.chatGptLabel).toBeUndefined(); + }); + + it('should prioritize modelLabel over chatGptLabel when both exist', () => { + const preset = { + ...basePreset, + chatGptLabel: 'Old ChatGPT Label', + modelLabel: 'New Model Label', + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('New Model Label'); + expect(result.chatGptLabel).toBeUndefined(); + }); + + it('should keep modelLabel when only modelLabel exists', () => { + const preset = { + ...basePreset, + modelLabel: 'Existing Model Label', + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('Existing Model Label'); + expect(result.chatGptLabel).toBeUndefined(); + }); + + it('should handle preset without either label', () => { + const preset = { ...basePreset }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBeUndefined(); + expect(result.chatGptLabel).toBeUndefined(); + }); + + it('should handle empty chatGptLabel', () => { + const preset = { + ...basePreset, + chatGptLabel: '', + modelLabel: 'Valid Model Label', + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('Valid Model Label'); + expect(result.chatGptLabel).toBeUndefined(); + }); + + it('should not migrate empty string chatGptLabel when modelLabel exists', () => { + const preset = { + ...basePreset, + chatGptLabel: '', + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBeUndefined(); + expect(result.chatGptLabel).toBeUndefined(); + }); + }); + + describe('presetOverride handling', () => { + it('should apply presetOverride and then handle label migration', () => { + const preset = { + ...basePreset, + chatGptLabel: 'Original Label', + presetOverride: { + modelLabel: 'Override Model Label', + temperature: 0.9, + }, + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('Override Model Label'); + expect(result.chatGptLabel).toBeUndefined(); + expect(result.temperature).toBe(0.9); + }); + + it('should handle label migration in presetOverride', () => { + const preset = { + ...basePreset, + presetOverride: { + chatGptLabel: 'Override ChatGPT Label', + }, + }; + + const result = cleanupPreset({ preset }); + + expect(result.modelLabel).toBe('Override ChatGPT Label'); + expect(result.chatGptLabel).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle undefined preset', () => { + const result = cleanupPreset({ preset: undefined }); + + expect(result).toEqual({ + endpoint: null, + presetId: null, + title: 'New Preset', + }); + }); + + it('should handle preset with null endpoint', () => { + const preset = { + ...basePreset, + endpoint: null, + }; + + const result = cleanupPreset({ preset }); + + expect(result).toEqual({ + endpoint: null, + presetId: 'test-preset-id', + title: 'Test Preset', + }); + }); + + it('should handle preset with empty string endpoint', () => { + const preset = { + ...basePreset, + endpoint: '', + }; + + const result = cleanupPreset({ preset }); + + expect(result).toEqual({ + endpoint: null, + presetId: 'test-preset-id', + title: 'Test Preset', + }); + }); + }); + + describe('normal preset properties', () => { + it('should preserve all other preset properties', () => { + const preset = { + ...basePreset, + promptPrefix: 'Custom prompt:', + temperature: 0.8, + top_p: 0.9, + modelLabel: 'Custom Model', + tools: ['plugin1', 'plugin2'], + }; + + const result = cleanupPreset({ preset }); + + expect(result.presetId).toBe('test-preset-id'); + expect(result.title).toBe('Test Preset'); + expect(result.endpoint).toBe(EModelEndpoint.openAI); + expect(result.modelLabel).toBe('Custom Model'); + expect(result.promptPrefix).toBe('Custom prompt:'); + expect(result.temperature).toBe(0.8); + expect(result.top_p).toBe(0.9); + expect(result.tools).toEqual(['plugin1', 'plugin2']); + }); + + it('should generate default title when title is missing', () => { + const preset = { + ...basePreset, + title: undefined, + }; + + const result = cleanupPreset({ preset }); + + expect(result.title).toBe('New Preset'); + }); + + it('should handle null presetId', () => { + const preset = { + ...basePreset, + presetId: null, + }; + + const result = cleanupPreset({ preset }); + + expect(result.presetId).toBeNull(); + }); + }); +}); diff --git a/client/src/utils/__tests__/presets.test.ts b/client/src/utils/__tests__/presets.test.ts new file mode 100644 index 0000000000..0d54602c24 --- /dev/null +++ b/client/src/utils/__tests__/presets.test.ts @@ -0,0 +1,362 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import { getPresetTitle, removeUnavailableTools } from '../presets'; +import type { TPreset, TPlugin } from 'librechat-data-provider'; + +describe('presets utils', () => { + describe('getPresetTitle', () => { + const basePreset: TPreset = { + presetId: 'test-id', + title: 'Test Preset', + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + }; + + describe('with modelLabel', () => { + it('should use modelLabel as the label', () => { + const preset = { + ...basePreset, + modelLabel: 'Custom Model Name', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: gpt-4 (Custom Model Name)'); + }); + + it('should prioritize modelLabel over deprecated chatGptLabel', () => { + const preset = { + ...basePreset, + modelLabel: 'New Model Label', + chatGptLabel: 'Old ChatGPT Label', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: gpt-4 (New Model Label)'); + }); + + it('should handle title that includes the label', () => { + const preset = { + ...basePreset, + title: 'Custom Model Name Settings', + modelLabel: 'Custom Model Name', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Custom Model Name Settings: gpt-4 (Custom Model Name)'); + }); + + it('should handle case-insensitive title matching', () => { + const preset = { + ...basePreset, + title: 'custom model name preset', + modelLabel: 'Custom Model Name', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('custom model name preset: gpt-4 (Custom Model Name)'); + }); + + it('should use label as title when label includes the title', () => { + const preset = { + ...basePreset, + title: 'GPT', + modelLabel: 'Custom GPT Assistant', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Custom GPT Assistant: gpt-4'); + }); + }); + + describe('without modelLabel', () => { + it('should work without modelLabel', () => { + const preset = { ...basePreset }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: gpt-4'); + }); + + it('should handle empty modelLabel', () => { + const preset = { + ...basePreset, + modelLabel: '', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: gpt-4'); + }); + + it('should handle null modelLabel', () => { + const preset = { + ...basePreset, + modelLabel: null, + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: gpt-4'); + }); + }); + + describe('title variations', () => { + it('should handle missing title', () => { + const preset = { + ...basePreset, + title: null, + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('gpt-4 (Custom Model)'); + }); + + it('should handle empty title', () => { + const preset = { + ...basePreset, + title: '', + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('gpt-4 (Custom Model)'); + }); + + it('should handle "New Chat" title', () => { + const preset = { + ...basePreset, + title: 'New Chat', + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('gpt-4 (Custom Model)'); + }); + + it('should handle title with whitespace', () => { + const preset = { + ...basePreset, + title: ' ', + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe(': gpt-4 (Custom Model)'); + }); + }); + + describe('mention mode', () => { + it('should return mention format with all components', () => { + const preset = { + ...basePreset, + modelLabel: 'Custom Model', + promptPrefix: 'You are a helpful assistant', + tools: ['plugin1', 'plugin2'] as string[], + }; + + const result = getPresetTitle(preset, true); + + expect(result).toBe( + 'gpt-4 | Custom Model | You are a helpful assistant | plugin1, plugin2', + ); + }); + + it('should handle mention format with object tools', () => { + const preset = { + ...basePreset, + modelLabel: 'Custom Model', + tools: [ + { pluginKey: 'plugin1', name: 'Plugin 1' } as TPlugin, + { pluginKey: 'plugin3', name: 'Plugin 3' } as TPlugin, + ] as TPlugin[], + }; + + const result = getPresetTitle(preset, true); + + expect(result).toBe('gpt-4 | Custom Model | plugin1, plugin3'); + }); + + it('should handle mention format with minimal data', () => { + const preset = { ...basePreset }; + + const result = getPresetTitle(preset, true); + + expect(result).toBe('gpt-4'); + }); + + it('should handle mention format with only modelLabel', () => { + const preset = { + ...basePreset, + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset, true); + + expect(result).toBe('gpt-4 | Custom Model'); + }); + + it('should handle mention format with only promptPrefix', () => { + const preset = { + ...basePreset, + promptPrefix: 'Custom prompt', + }; + + const result = getPresetTitle(preset, true); + + expect(result).toBe('gpt-4 | Custom prompt'); + }); + }); + + describe('edge cases', () => { + it('should handle missing model', () => { + const preset = { + ...basePreset, + model: null, + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: (Custom Model)'); + }); + + it('should handle undefined model', () => { + const preset = { + ...basePreset, + model: undefined, + modelLabel: 'Custom Model', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe('Test Preset: (Custom Model)'); + }); + + it('should trim the final result', () => { + const preset = { + ...basePreset, + title: '', + model: '', + modelLabel: '', + }; + + const result = getPresetTitle(preset); + + expect(result).toBe(''); + }); + }); + }); + + describe('removeUnavailableTools', () => { + const basePreset: TPreset = { + presetId: 'test-id', + title: 'Test Preset', + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + }; + + const availableTools: Record = { + plugin1: { pluginKey: 'plugin1', name: 'Plugin 1' } as TPlugin, + plugin2: { pluginKey: 'plugin2', name: 'Plugin 2' } as TPlugin, + plugin3: { pluginKey: 'plugin3', name: 'Plugin 3' } as TPlugin, + }; + + it('should remove unavailable tools from string array', () => { + const preset = { + ...basePreset, + tools: ['plugin1', 'unavailable-plugin', 'plugin2'] as string[], + }; + + const result = removeUnavailableTools(preset, availableTools); + + expect(result.tools).toEqual(['plugin1', 'plugin2']); + }); + + it('should remove unavailable tools from object array', () => { + const preset = { + ...basePreset, + tools: [ + { pluginKey: 'plugin1', name: 'Plugin 1' } as TPlugin, + { pluginKey: 'unavailable-plugin', name: 'Unavailable' } as TPlugin, + { pluginKey: 'plugin2', name: 'Plugin 2' } as TPlugin, + ] as TPlugin[], + }; + + const result = removeUnavailableTools(preset, availableTools); + + expect(result.tools).toEqual(['plugin1', 'plugin2']); + }); + + it('should handle preset without tools', () => { + const preset = { ...basePreset }; + + const result = removeUnavailableTools(preset, availableTools); + + expect(result).toEqual(preset); + }); + + it('should handle preset with empty tools array', () => { + const preset = { + ...basePreset, + tools: [] as string[], + }; + + const result = removeUnavailableTools(preset, availableTools); + + expect(result.tools).toEqual([]); + }); + + it('should remove all tools when none are available', () => { + const preset = { + ...basePreset, + tools: ['unavailable1', 'unavailable2'] as string[], + }; + + const result = removeUnavailableTools(preset, {}); + + expect(result.tools).toEqual([]); + }); + + it('should preserve all other preset properties', () => { + const preset = { + ...basePreset, + tools: ['plugin1'] as string[], + modelLabel: 'Custom Model', + temperature: 0.8, + promptPrefix: 'Test prompt', + }; + + const result = removeUnavailableTools(preset, availableTools); + + expect(result.presetId).toBe(preset.presetId); + expect(result.title).toBe(preset.title); + expect(result.endpoint).toBe(preset.endpoint); + expect(result.model).toBe(preset.model); + expect(result.modelLabel).toBe(preset.modelLabel); + expect(result.temperature).toBe(preset.temperature); + expect(result.promptPrefix).toBe(preset.promptPrefix); + expect(result.tools).toEqual(['plugin1']); + }); + + it('should not mutate the original preset', () => { + const preset = { + ...basePreset, + tools: ['plugin1', 'unavailable-plugin'] as string[], + }; + const originalTools = [...preset.tools]; + + removeUnavailableTools(preset, availableTools); + + expect(preset.tools).toEqual(originalTools); + }); + }); +}); diff --git a/client/src/utils/cleanupPreset.ts b/client/src/utils/cleanupPreset.ts index 7329e91e72..c158d935fa 100644 --- a/client/src/utils/cleanupPreset.ts +++ b/client/src/utils/cleanupPreset.ts @@ -20,6 +20,21 @@ const cleanupPreset = ({ preset: _preset }: TCleanupPreset): TPreset => { const { presetOverride = {}, ...rest } = _preset ?? {}; const preset = { ...rest, ...presetOverride }; + // Handle deprecated chatGptLabel field + // If both chatGptLabel and modelLabel exist, prioritize modelLabel and remove chatGptLabel + // If only chatGptLabel exists, migrate it to modelLabel + if (preset.chatGptLabel && preset.modelLabel) { + // Both exist: prioritize modelLabel, remove chatGptLabel + delete preset.chatGptLabel; + } else if (preset.chatGptLabel && !preset.modelLabel) { + // Only chatGptLabel exists: migrate to modelLabel + preset.modelLabel = preset.chatGptLabel; + delete preset.chatGptLabel; + } else if ('chatGptLabel' in preset) { + // chatGptLabel exists but is empty/falsy: remove it + delete preset.chatGptLabel; + } + /* @ts-ignore: endpoint can be a custom defined name */ const parsedPreset = parseConvo({ endpoint, endpointType, conversation: preset }); diff --git a/client/src/utils/presets.ts b/client/src/utils/presets.ts index daa1327bff..cf6933844b 100644 --- a/client/src/utils/presets.ts +++ b/client/src/utils/presets.ts @@ -17,18 +17,10 @@ export const getPresetTitle = (preset: TPreset, mention?: boolean) => { let title = ''; let label = ''; - const usesChatGPTLabel: TEndpoints = [ - EModelEndpoint.azureOpenAI, - EModelEndpoint.openAI, - EModelEndpoint.custom, - ]; - const usesModelLabel: TEndpoints = [EModelEndpoint.google, EModelEndpoint.anthropic]; - - if (endpoint != null && endpoint && usesChatGPTLabel.includes(endpoint)) { - label = chatGptLabel ?? ''; - } else if (endpoint != null && endpoint && usesModelLabel.includes(endpoint)) { - label = modelLabel ?? ''; + if (modelLabel) { + label = modelLabel; } + if ( label && presetTitle != null && @@ -47,13 +39,13 @@ export const getPresetTitle = (preset: TPreset, mention?: boolean) => { }${ tools ? ` | ${tools - .map((tool: TPlugin | string) => { - if (typeof tool === 'string') { - return tool; - } - return tool.pluginKey; - }) - .join(', ')}` + .map((tool: TPlugin | string) => { + if (typeof tool === 'string') { + return tool; + } + return tool.pluginKey; + }) + .join(', ')}` : '' }`; }