diff --git a/client/src/components/Nav/ExportConversation/ExportModal.tsx b/client/src/components/Nav/ExportConversation/ExportModal.tsx index f9cd73875c..4303856e57 100644 --- a/client/src/components/Nav/ExportConversation/ExportModal.tsx +++ b/client/src/components/Nav/ExportConversation/ExportModal.tsx @@ -11,6 +11,7 @@ import { } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; import { useLocalize, useExportConversation } from '~/hooks'; +import { normalizeExportFilename } from '~/utils'; const TYPE_OPTIONS = [ { value: 'screenshot', label: 'screenshot (.png)' }, @@ -72,7 +73,7 @@ export default function ExportModal({ const { exportConversation } = useExportConversation({ conversation, - filename: filenamify(filename), + filename: normalizeExportFilename(filenamify(filename)), type, includeOptions, exportBranches, diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index 5ead899004..93929fef10 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -1,18 +1,23 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; import filenamify from 'filenamify'; import exportFromJSON from 'export-from-json'; import { useToastContext } from '@librechat/client'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil'; import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TPreset, TEndpointsConfig } from 'librechat-data-provider'; +import { + normalizeExportFilename, + removeUnavailableTools, + getConvoSwitchLogic, + cleanupPreset, +} from '~/utils'; import { useUpdatePresetMutation, useDeletePresetMutation, useGetPresetsQuery, } from '~/data-provider'; -import { cleanupPreset, removeUnavailableTools, getConvoSwitchLogic } from '~/utils'; import useGetConversation from '~/hooks/Conversations/useGetConversation'; import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; import { useAuthContext } from '~/hooks/AuthContext'; @@ -264,7 +269,7 @@ export default function usePresets(index = 0) { if (!preset) { return; } - const fileName = filenamify(preset.title || 'preset'); + const fileName = normalizeExportFilename(filenamify(preset.title || 'preset')); exportFromJSON({ data: cleanupPreset({ preset }), fileName, diff --git a/client/src/utils/files.spec.ts b/client/src/utils/files.spec.ts new file mode 100644 index 0000000000..49c150b2bf --- /dev/null +++ b/client/src/utils/files.spec.ts @@ -0,0 +1,26 @@ +import { normalizeExportFilename } from './files'; + +describe('normalizeExportFilename', () => { + it('replaces every whitespace run with a single underscore', () => { + expect(normalizeExportFilename('Word1 Word2 Word3')).toBe('Word1_Word2_Word3'); + }); + + it('collapses consecutive whitespace into one underscore', () => { + expect(normalizeExportFilename('Word1 Word2\tWord3')).toBe('Word1_Word2_Word3'); + }); + + it('leaves filenames without whitespace unchanged', () => { + expect(normalizeExportFilename('single-word_name')).toBe('single-word_name'); + }); + + it('handles an empty string', () => { + expect(normalizeExportFilename('')).toBe(''); + }); + + it('never leaves whitespace, so downstream whitespace-based formatters are no-ops', () => { + const inputs = ['Word1 Word2 Word3', ' leading', 'trailing ', 'tab\there', 'a b c']; + for (const input of inputs) { + expect(normalizeExportFilename(input)).not.toMatch(/\s/); + } + }); +}); diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index b680a18c2d..d575f919b4 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -439,3 +439,12 @@ export function sortPagesByRelevance( } return [...pages].sort((a, b) => (pageRelevance[b] || 0) - (pageRelevance[a] || 0)); } + +/** + * Collapses whitespace runs to underscores so export filenames come out + * identical across all export formats: `export-from-json`'s default formatter + * only replaces the first whitespace run, while direct downloads replace none. + */ +export function normalizeExportFilename(filename: string): string { + return filename.replace(/\s+/g, '_'); +}