🧠 fix: Format Reasoning Exports (#14116)

* fix: Format reasoning exports

* chore: Sort export imports

* fix: Refine reasoning export formatting

* chore: Simplify export test localization

* fix: Handle export summary labels
This commit is contained in:
Danny Avila 2026-07-05 12:03:23 -04:00 • committed by GitHub
parent 13a01dee9b
commit 1f7b38e972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 487 additions and 120 deletions

View file

@ -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<string, string> = {
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, ExportableContentPart> = {
[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, MessageContentExport> = {
[ContentTypes.TEXT]: ['Assistant', 'Visible answer'],
[ContentTypes.THINK]: [
'Assistant',
'<details>\n<summary>Thinking</summary>\n\nReasoning step\n</details>',
],
[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',
'<details>\n<summary>Thinking</summary>\n\nThinking Process:\n\n1. Analyze the request\n</details>',
]);
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: '<think>\nCheck assumptions\n</think>' },
format: 'md',
localize,
}),
).toEqual([
'Assistant',
'<details>\n<summary>Thinking</summary>\n\nCheck assumptions\n</details>',
]);
});
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<TMessage>;
expect(formatMessageText({ message, format: 'md', localize })).toBe(
'**DeepInfra**\n<details>\n<summary>Thinking</summary>\n\nHidden chain\n</details>\n\n\n**DeepInfra**\nFinal answer',
);
});
});

View file

@ -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<TMessageContentParts, { type: ContentTypes.SUMMARY }>;
type ExportSummaryContentPart = Omit<SummaryContentPart, 'content'> & {
content?: Array<{ type: ContentTypes.TEXT; text?: TextValue }> | string;
text?: TextValue;
};
export type ExportableContentPart =
| Exclude<TMessageContentParts, SummaryContentPart>
| 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<string, Parameters<LocalizeFunction>[0]>;
const stripThinkTags = (reasoning: string): string =>
reasoning
.replace(/^<think>\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 `<details>\n<summary>${label}</summary>\n\n${reasoning}\n</details>`;
}
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<TMessage> | 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');
}

View file

@ -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<TMessage> | 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';
}

View file

@ -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...",