From 1f7b38e9729396453d9cee3f1b3e8f116ea38298 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Jul 2026 12:03:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20fix:=20Format=20Reasoning=20Expo?= =?UTF-8?q?rts=20(#14116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Format reasoning exports * chore: Sort export imports * fix: Refine reasoning export formatting * chore: Simplify export test localization * fix: Handle export summary labels --- .../Conversations/__tests__/format.test.ts | 239 ++++++++++++++++++ client/src/hooks/Conversations/format.ts | 231 +++++++++++++++++ .../Conversations/useExportConversation.ts | 129 +--------- client/src/locales/en/translation.json | 8 + 4 files changed, 487 insertions(+), 120 deletions(-) create mode 100644 client/src/hooks/Conversations/__tests__/format.test.ts create mode 100644 client/src/hooks/Conversations/format.ts diff --git a/client/src/hooks/Conversations/__tests__/format.test.ts b/client/src/hooks/Conversations/__tests__/format.test.ts new file mode 100644 index 0000000000..a7b7ca4ff2 --- /dev/null +++ b/client/src/hooks/Conversations/__tests__/format.test.ts @@ -0,0 +1,239 @@ +import { ContentTypes, ToolCallTypes } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; +import type { ExportableContentPart, MessageContentExport } from '../format'; +import type { LocalizeFunction } from '~/common'; +import { formatMessageText, formatMessageContent, handledExportContentTypes } from '../format'; + +const translations: Record = { + com_endpoint_thinking: 'Thinking', + com_ui_export_agent_update: 'Agent Update', + com_ui_export_audio: 'Audio', + com_ui_export_file_search: 'File Search', + com_ui_export_image: 'Image', + com_ui_export_retrieval: 'Retrieval', + com_ui_export_summary: 'Summary', + com_ui_export_tool: 'Tool', + com_ui_export_video: 'Video', + com_ui_run_code: 'Run code', +}; + +const localize: LocalizeFunction = ((key: string) => translations[key] ?? key) as LocalizeFunction; + +const imageFile = { + file_id: 'file-1', + filename: 'chart.png', + filepath: '/images/chart.png', + height: 480, + width: 640, +}; + +const contentByType: Record = { + [ContentTypes.TEXT]: { + type: ContentTypes.TEXT, + text: 'Visible answer', + }, + [ContentTypes.THINK]: { + type: ContentTypes.THINK, + think: 'Reasoning step', + }, + [ContentTypes.TEXT_DELTA]: { + type: ContentTypes.TEXT_DELTA, + text_delta: 'Streaming text', + }, + [ContentTypes.TOOL_CALL]: { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-1', + type: ToolCallTypes.CODE_INTERPRETER, + code_interpreter: { + input: 'print(1)', + outputs: [], + }, + }, + } as ExportableContentPart, + [ContentTypes.IMAGE_FILE]: { + type: ContentTypes.IMAGE_FILE, + image_file: imageFile, + } as ExportableContentPart, + [ContentTypes.IMAGE_URL]: { + type: ContentTypes.IMAGE_URL, + image_url: 'https://example.com/image.png', + }, + [ContentTypes.VIDEO_URL]: { + type: ContentTypes.VIDEO_URL, + video_url: { url: 'https://example.com/video.mp4' }, + }, + [ContentTypes.INPUT_AUDIO]: { + type: ContentTypes.INPUT_AUDIO, + input_audio: { + data: 'base64-audio', + format: 'wav', + }, + }, + [ContentTypes.AGENT_UPDATE]: { + type: ContentTypes.AGENT_UPDATE, + agent_update: { + index: 0, + runId: 'run-1', + agentId: 'agent-1', + }, + }, + [ContentTypes.SUMMARY]: { + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text: 'Summary text' }], + }, + [ContentTypes.ERROR]: { + type: ContentTypes.ERROR, + error: 'Something failed', + }, +}; + +const expectedMarkdownByType: Record = { + [ContentTypes.TEXT]: ['Assistant', 'Visible answer'], + [ContentTypes.THINK]: [ + 'Assistant', + '
\nThinking\n\nReasoning step\n
', + ], + [ContentTypes.TEXT_DELTA]: ['Assistant', 'Streaming text'], + [ContentTypes.TOOL_CALL]: ['Run code', '{"input":"print(1)","outputs":[]}'], + [ContentTypes.IMAGE_FILE]: ['Image', JSON.stringify(imageFile)], + [ContentTypes.IMAGE_URL]: ['Image', 'https://example.com/image.png'], + [ContentTypes.VIDEO_URL]: ['Video', 'https://example.com/video.mp4'], + [ContentTypes.INPUT_AUDIO]: ['Audio', '{"data":"base64-audio","format":"wav"}'], + [ContentTypes.AGENT_UPDATE]: ['Agent Update', '{"index":0,"runId":"run-1","agentId":"agent-1"}'], + [ContentTypes.SUMMARY]: ['Summary', 'Summary text'], + [ContentTypes.ERROR]: ['Assistant', 'Something failed'], +}; + +describe('handledExportContentTypes', () => { + it('accounts for every content type before formatting exports', () => { + expect([...handledExportContentTypes].sort()).toEqual(Object.values(ContentTypes).sort()); + }); +}); + +describe('formatMessageContent', () => { + it.each(Object.values(ContentTypes))('formats %s content explicitly', (type) => { + expect( + formatMessageContent({ + sender: 'Assistant', + content: contentByType[type], + format: 'md', + localize, + }), + ).toEqual(expectedMarkdownByType[type]); + }); + + it('formats reasoning content as readable markdown instead of raw JSON', () => { + const result = formatMessageContent({ + sender: 'DeepInfra', + content: { + type: ContentTypes.THINK, + think: 'Thinking Process:\n\n1. Analyze the request', + }, + format: 'md', + localize, + }); + + expect(result).toEqual([ + 'DeepInfra', + '
\nThinking\n\nThinking Process:\n\n1. Analyze the request\n
', + ]); + expect(result[1]).not.toContain('{"type":"think"'); + }); + + it('formats reasoning content readably for text exports', () => { + expect( + formatMessageContent({ + sender: 'Assistant', + content: { type: ContentTypes.THINK, think: 'Check assumptions' }, + localize, + }), + ).toEqual(['Assistant', 'Thinking:\nCheck assumptions']); + }); + + it('strips THINK delimiters before formatting reasoning exports', () => { + expect( + formatMessageContent({ + sender: 'Assistant', + content: { type: ContentTypes.THINK, think: '\nCheck assumptions\n' }, + format: 'md', + localize, + }), + ).toEqual([ + 'Assistant', + '
\nThinking\n\nCheck assumptions\n
', + ]); + }); + + it('concatenates summary chunks without inserted paragraphs', () => { + expect( + formatMessageContent({ + sender: 'Assistant', + content: { + type: ContentTypes.SUMMARY, + content: [ + { type: ContentTypes.TEXT, text: 'The ' }, + { type: ContentTypes.TEXT, text: 'answer' }, + ], + }, + format: 'md', + localize, + }), + ).toEqual(['Summary', 'The answer']); + }); + + it('formats legacy summary string content', () => { + expect( + formatMessageContent({ + sender: 'Assistant', + content: { + type: ContentTypes.SUMMARY, + content: 'Legacy summary text', + }, + format: 'md', + localize, + }), + ).toEqual(['Summary', 'Legacy summary text']); + }); + + it('localizes export section labels', () => { + const localized = ((key: string) => `localized:${key}`) as LocalizeFunction; + + expect( + formatMessageContent({ + sender: 'Assistant', + content: contentByType[ContentTypes.VIDEO_URL], + format: 'md', + localize: localized, + }), + ).toEqual(['localized:com_ui_export_video', 'https://example.com/video.mp4']); + + expect( + formatMessageContent({ + sender: 'Assistant', + content: contentByType[ContentTypes.AGENT_UPDATE], + format: 'md', + localize: localized, + }), + ).toEqual([ + 'localized:com_ui_export_agent_update', + '{"index":0,"runId":"run-1","agentId":"agent-1"}', + ]); + }); +}); + +describe('formatMessageText', () => { + it('formats markdown messages with reasoning and visible text parts', () => { + const message = { + sender: 'DeepInfra', + content: [ + { type: ContentTypes.THINK, think: 'Hidden chain' }, + { type: ContentTypes.TEXT, text: 'Final answer' }, + ], + } as Partial; + + expect(formatMessageText({ message, format: 'md', localize })).toBe( + '**DeepInfra**\n
\nThinking\n\nHidden chain\n
\n\n\n**DeepInfra**\nFinal answer', + ); + }); +}); diff --git a/client/src/hooks/Conversations/format.ts b/client/src/hooks/Conversations/format.ts new file mode 100644 index 0000000000..0e872e5e87 --- /dev/null +++ b/client/src/hooks/Conversations/format.ts @@ -0,0 +1,231 @@ +import { ContentTypes, ToolCallTypes } from 'librechat-data-provider'; + +import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; +import type { LocalizeFunction } from '~/common'; + +export type ExportFormat = 'text' | 'md'; +export type MessageContentExport = [] | [sender: string, text: string]; + +type TextValue = string | { value?: string | null } | null | undefined; +type TextDeltaContentPart = { + type: ContentTypes.TEXT_DELTA; + text?: TextValue; + text_delta?: TextValue; +}; +type SummaryContentPart = Extract; +type ExportSummaryContentPart = Omit & { + content?: Array<{ type: ContentTypes.TEXT; text?: TextValue }> | string; + text?: TextValue; +}; + +export type ExportableContentPart = + | Exclude + | ExportSummaryContentPart + | TextDeltaContentPart; + +export const handledExportContentTypes = [ + ContentTypes.TEXT, + ContentTypes.THINK, + ContentTypes.TEXT_DELTA, + ContentTypes.TOOL_CALL, + ContentTypes.IMAGE_FILE, + ContentTypes.IMAGE_URL, + ContentTypes.VIDEO_URL, + ContentTypes.INPUT_AUDIO, + ContentTypes.AGENT_UPDATE, + ContentTypes.SUMMARY, + ContentTypes.ERROR, +] satisfies readonly ContentTypes[]; + +const getTextValue = (value: TextValue): string => { + if (typeof value === 'string') { + return value; + } + return value?.value ?? ''; +}; + +const stringify = (value: unknown): string => JSON.stringify(value ?? null); + +const hasFormattedContent = (content: MessageContentExport): content is [string, string] => + content.length > 0; + +const exportLabelKeys = { + tool: 'com_ui_export_tool', + image: 'com_ui_export_image', + audio: 'com_ui_export_audio', + video: 'com_ui_export_video', + summary: 'com_ui_export_summary', + retrieval: 'com_ui_export_retrieval', + fileSearch: 'com_ui_export_file_search', + agentUpdate: 'com_ui_export_agent_update', +} satisfies Record[0]>; + +const stripThinkTags = (reasoning: string): string => + reasoning + .replace(/^\s*/, '') + .replace(/\s*<\/think>$/, '') + .trim(); + +const formatReasoning = ({ + reasoning, + format, + localize, +}: { + reasoning: string; + format: ExportFormat; + localize: LocalizeFunction; +}): string => { + const label = localize('com_endpoint_thinking'); + if (format === 'md') { + return `
\n${label}\n\n${reasoning}\n
`; + } + return `${label}:\n${reasoning}`; +}; + +const getUrlText = (value: string | { url?: string } | undefined): string => { + if (typeof value === 'string') { + return value; + } + return value?.url ?? stringify(value); +}; + +const getSummaryText = (content: ExportSummaryContentPart): string | undefined => { + if (Array.isArray(content.content)) { + return content.content.map((part) => getTextValue(part.text)).join(''); + } + if (typeof content.content === 'string') { + return content.content; + } + const text = getTextValue(content.text); + if (text.length > 0) { + return text; + } + return undefined; +}; + +export function formatMessageContent({ + sender, + content, + format = 'text', + localize, +}: { + sender: string; + content?: ExportableContentPart; + format?: ExportFormat; + localize: LocalizeFunction; +}): MessageContentExport { + if (!content) { + return []; + } + + if (content.type === ContentTypes.ERROR) { + return [sender, content.error ?? getTextValue(content.text)]; + } + + if (content.type === ContentTypes.TEXT) { + const text = getTextValue(content.text); + if (text.trim().length === 0) { + return []; + } + return [sender, text]; + } + + if (content.type === ContentTypes.THINK) { + const reasoning = stripThinkTags(getTextValue(content.think)); + if (reasoning.trim().length === 0) { + return []; + } + return [sender, formatReasoning({ reasoning, format, localize })]; + } + + if (content.type === ContentTypes.TEXT_DELTA) { + const text = getTextValue(content.text_delta ?? content.text); + if (text.trim().length === 0) { + return []; + } + return [sender, text]; + } + + if (content.type === ContentTypes.TOOL_CALL) { + const toolCall = content.tool_call; + if (!toolCall) { + return [localize(exportLabelKeys.tool), stringify(toolCall)]; + } + if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { + return [localize('com_ui_run_code'), stringify(toolCall.code_interpreter)]; + } + if (toolCall.type === ToolCallTypes.RETRIEVAL) { + return [localize(exportLabelKeys.retrieval), stringify(toolCall)]; + } + if (toolCall.type === ToolCallTypes.FILE_SEARCH) { + return [localize(exportLabelKeys.fileSearch), stringify(toolCall)]; + } + return [localize(exportLabelKeys.tool), stringify(toolCall)]; + } + + if (content.type === ContentTypes.IMAGE_FILE) { + return [localize(exportLabelKeys.image), stringify(content.image_file)]; + } + + if (content.type === ContentTypes.IMAGE_URL) { + return [localize(exportLabelKeys.image), getUrlText(content.image_url)]; + } + + if (content.type === ContentTypes.VIDEO_URL) { + return [localize(exportLabelKeys.video), getUrlText(content.video_url)]; + } + + if (content.type === ContentTypes.INPUT_AUDIO) { + return [localize(exportLabelKeys.audio), stringify(content.input_audio)]; + } + + if (content.type === ContentTypes.AGENT_UPDATE) { + return [localize(exportLabelKeys.agentUpdate), stringify(content.agent_update)]; + } + + if (content.type === ContentTypes.SUMMARY) { + const summary = getSummaryText(content); + return [localize(exportLabelKeys.summary), summary ?? stringify(content)]; + } + + return [sender, stringify(content)]; +} + +export function formatMessageText({ + message, + format = 'text', + localize, +}: { + message: Partial | undefined; + format?: ExportFormat; + localize: LocalizeFunction; +}): string { + if (!message) { + return ''; + } + + const formatText = (sender: string, text: string) => { + if (format === 'text') { + return `>> ${sender}:\n${text}`; + } + return `**${sender}**\n${text}`; + }; + + if (!message.content) { + return formatText(message.sender || '', message.text || ''); + } + + return message.content + .filter((content) => content != null) + .map((content) => + formatMessageContent({ + sender: message.sender || '', + content, + format, + localize, + }), + ) + .filter(hasFormattedContent) + .map((text) => formatText(text[0], text[1])) + .join('\n\n\n'); +} diff --git a/client/src/hooks/Conversations/useExportConversation.ts b/client/src/hooks/Conversations/useExportConversation.ts index 22fd2a2a73..c2c16aa7f2 100644 --- a/client/src/hooks/Conversations/useExportConversation.ts +++ b/client/src/hooks/Conversations/useExportConversation.ts @@ -1,26 +1,15 @@ -import download from 'downloadjs'; import { useCallback } from 'react'; +import download from 'downloadjs'; import { useParams } from 'react-router-dom'; import exportFromJSON from 'export-from-json'; import { useQueryClient } from '@tanstack/react-query'; -import { - buildTree, - QueryKeys, - ContentTypes, - ToolCallTypes, - imageGenTools, - isImageVisionTool, -} from 'librechat-data-provider'; -import type { - TMessageContentParts, - TConversation, - TMessage, - TPreset, -} from 'librechat-data-provider'; +import { buildTree, QueryKeys } from 'librechat-data-provider'; +import type { TConversation, TMessage, TPreset } from 'librechat-data-provider'; import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree'; import { useScreenshot } from '~/hooks/ScreenshotContext'; -import { useLocalize } from '~/hooks'; +import { formatMessageText } from './format'; import { cleanupPreset } from '~/utils'; +import { useLocalize } from '~/hooks'; type ExportValues = { fieldName: string; @@ -58,106 +47,6 @@ export default function useExportConversation({ return dataTree?.length === 0 ? null : (dataTree ?? null); }, [paramId, conversation?.conversationId, queryClient]); - const getMessageText = (message: Partial | undefined, format = 'text') => { - if (!message) { - return ''; - } - - const formatText = (sender: string, text: string) => { - if (format === 'text') { - return `>> ${sender}:\n${text}`; - } - return `**${sender}**\n${text}`; - }; - - if (!message.content) { - return formatText(message.sender || '', message.text || ''); - } - - return message.content - .filter((content) => content != null) - .map((content) => getMessageContent(message.sender || '', content)) - .filter((text) => text.length > 0) - .map((text) => { - return formatText(text[0], text[1]); - }) - .join('\n\n\n'); - }; - - /** - * Format and return message texts according to the type of content. - * Currently, content whose type is `TOOL_CALL` basically returns JSON as is. - * In the future, different formatted text may be returned for each type. - */ - const getMessageContent = (sender: string, content?: TMessageContentParts): string[] => { - if (!content) { - return []; - } - - if (content.type === ContentTypes.ERROR) { - // ERROR - return [ - sender, - typeof content[ContentTypes.TEXT] === 'object' - ? (content[ContentTypes.TEXT].value ?? '') - : (content[ContentTypes.TEXT] ?? ''), - ]; - } - - if (content.type === ContentTypes.TEXT) { - // TEXT - const textPart = content[ContentTypes.TEXT]; - const text = typeof textPart === 'string' ? textPart : (textPart?.value ?? ''); - if (text.trim().length === 0) { - return []; - } - return [sender, text]; - } - - if (content.type === ContentTypes.TOOL_CALL) { - const type = content[ContentTypes.TOOL_CALL].type; - - if (type === ToolCallTypes.CODE_INTERPRETER) { - // CODE_INTERPRETER - const toolCall = content[ContentTypes.TOOL_CALL]; - const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER]; - return [localize('com_ui_run_code'), JSON.stringify(code_interpreter)]; - } - - if (type === ToolCallTypes.RETRIEVAL) { - // RETRIEVAL - const toolCall = content[ContentTypes.TOOL_CALL]; - return ['Retrieval', JSON.stringify(toolCall)]; - } - - if ( - type === ToolCallTypes.FUNCTION && - imageGenTools.has(content[ContentTypes.TOOL_CALL].function.name) - ) { - // IMAGE_GENERATION - const toolCall = content[ContentTypes.TOOL_CALL]; - return ['Tool', JSON.stringify(toolCall)]; - } - - if (type === ToolCallTypes.FUNCTION) { - // IMAGE_VISION - const toolCall = content[ContentTypes.TOOL_CALL]; - if (isImageVisionTool(toolCall)) { - return ['Tool', JSON.stringify(toolCall)]; - } - return ['Tool', JSON.stringify(toolCall)]; - } - } - - if (content.type === ContentTypes.IMAGE_FILE) { - // IMAGE - const imageFile = content[ContentTypes.IMAGE_FILE]; - return ['Image', JSON.stringify(imageFile)]; - } - - return [sender, JSON.stringify(content)]; - }; - const exportScreenshot = async () => { let data; try { @@ -261,7 +150,7 @@ export default function useExportConversation({ data += '\n## History\n'; if (Array.isArray(messages)) { for (const message of messages) { - data += `${getMessageText(message, 'md')}\n`; + data += `${formatMessageText({ message, format: 'md', localize })}\n`; if (message?.error) { data += '*(This is an error message)*\n'; } @@ -271,7 +160,7 @@ export default function useExportConversation({ data += '\n\n'; } } else { - data += `${getMessageText(messages, 'md')}\n`; + data += `${formatMessageText({ message: messages, format: 'md', localize })}\n`; if (messages.error) { data += '*(This is an error message)*\n'; } @@ -317,7 +206,7 @@ export default function useExportConversation({ data += '\nHistory\n########################\n'; if (Array.isArray(messages)) { for (const message of messages) { - data += `${getMessageText(message)}\n`; + data += `${formatMessageText({ message, localize })}\n`; if (message?.error) { data += '(This is an error message)\n'; } @@ -327,7 +216,7 @@ export default function useExportConversation({ data += '\n\n'; } } else { - data += `${getMessageText(messages)}\n`; + data += `${formatMessageText({ message: messages, localize })}\n`; if (messages.error) { data += '(This is an error message)\n'; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 735f62daeb..6f68d343d9 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1153,7 +1153,15 @@ "com_ui_expand_chat": "Expand Chat", "com_ui_expand_editor": "Expand editor", "com_ui_expand_thoughts": "Expand Thoughts", + "com_ui_export_agent_update": "Agent Update", + "com_ui_export_audio": "Audio", "com_ui_export_convo_modal": "Export Conversation Modal", + "com_ui_export_file_search": "File Search", + "com_ui_export_image": "Image", + "com_ui_export_retrieval": "Retrieval", + "com_ui_export_summary": "Summary", + "com_ui_export_tool": "Tool", + "com_ui_export_video": "Video", "com_ui_failed": "Failed", "com_ui_favorite": "Add to favorites", "com_ui_feedback_more": "More...",