:::warning
text
:::
text
'); + }); + + it('follows the LaTeX parsing setting for assistant messages', () => { + const withLatex = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: false, latexParsing: true }, + false, + ); + + act(() => { + withLatex.result.current(mockSetIsCopied); + }); + + expect(copiedHtml()).toBe('E=mc^2
'); + + jest.clearAllMocks(); + mockCopy.mockReturnValue(true); + + const withoutLatex = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: false, latexParsing: false }, + false, + ); + + act(() => { + withoutLatex.result.current(mockSetIsCopied); + }); + + expect(copiedHtml()).toBe('$E=mc^2$
'); + }); + + it('honors a forced variant over the authorship default', () => { + const { result } = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: false, latexParsing: true }, + true, + 'lite', + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(true); + expect(copiedHtml()).toBe('$E=mc^2$
'); + }); + + it('stays plain for an errored row, which ErrorMessage renders instead', () => { + const { result } = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: true }, + false, + undefined, + true, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(false); + }); + + it('stays plain for every author when the preference is off', () => { + const { result } = renderWithSettings( + { copyRichText: false, enableUserMsgMarkdown: true }, + false, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(false); + }); +}); diff --git a/client/src/hooks/Messages/useCopyToClipboard.spec.ts b/client/src/hooks/Messages/useCopyToClipboard.spec.ts index 7a1c43b9c4..96c02ec841 100644 --- a/client/src/hooks/Messages/useCopyToClipboard.spec.ts +++ b/client/src/hooks/Messages/useCopyToClipboard.spec.ts @@ -660,4 +660,206 @@ Citations: expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); }); }); + + describe('File citations', () => { + it('resolves a file citation against the references collection', () => { + const { result } = renderHook(() => + useCopyToClipboard({ + text: 'From the handbook \ue202turn0file0.', + searchResults: { + '0': { + references: [{ link: 'https://example.com/handbook.pdf', title: 'Handbook' }], + }, + } as unknown as { [key: string]: SearchResultData }, + }), + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + const [plainText] = mockCopy.mock.calls[0]; + expect(plainText).toContain('From the handbook [1].'); + expect(plainText).toContain('[1] https://example.com/handbook.pdf'); + }); + }); + + describe('Rich text', () => { + const getClipboardHtml = (): string => { + const [, options] = mockCopy.mock.calls[0]; + const clipboardData = { setData: jest.fn() }; + options?.onCopy?.(clipboardData); + const [format, html] = clipboardData.setData.mock.calls[0] ?? []; + expect(format).toBe('text/html'); + return html as string; + }; + + it('should not add an html flavor when rich text is off', () => { + const { result } = renderHook(() => useCopyToClipboard({ text: '# Title' })); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(mockCopy).toHaveBeenCalledWith('# Title', { format: 'text/plain' }); + }); + + it('should copy markdown as html alongside the plain text', () => { + const { result } = renderHook(() => + useCopyToClipboard({ + text: '# Title\n\nSome **bold** text.', + richText: { variant: 'full', latex: false }, + }), + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + const [plainText, options] = mockCopy.mock.calls[0]; + expect(plainText).toBe('# Title\n\nSome **bold** text.'); + expect(options?.format).toBe('text/plain'); + expect(getClipboardHtml()).toBe('Some bold text.
'); + expect(mockSetIsCopied).toHaveBeenCalledWith(true); + }); + + it('should convert content parts and citations to html', () => { + const mockSearchResults: { [key: string]: SearchResultData } = { + '0': { organic: [{ link: 'https://example.com/1', title: 'Source 1' }] }, + }; + + const { result } = renderHook(() => + useCopyToClipboard({ + content: [ + { type: ContentTypes.TEXT, text: '## Findings' }, + { type: ContentTypes.TEXT, text: 'Cited \ue202turn0search0' }, + ] as TMessageContentParts[], + searchResults: mockSearchResults, + richText: { variant: 'full', latex: false }, + }), + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + const html = getClipboardHtml(); + expect(html).toContain('Prose that renders on its own.
'); + expect(html).not.toContain('Prose');
+ });
+
+ it('does not let a definition capture a generated citation marker', () => {
+ const { result } = renderHook(() =>
+ useCopyToClipboard({
+ text: 'Cited \ue202turn0search0.\n\n[1]: https://other.example',
+ searchResults: {
+ '0': { organic: [{ link: 'https://example.com/1', title: 'Source 1' }] },
+ } as { [key: string]: SearchResultData },
+ richText: { variant: 'full', latex: false },
+ }),
+ );
+
+ act(() => {
+ result.current(mockSetIsCopied);
+ });
+
+ const html = getClipboardHtml();
+ expect(html).toContain('Cited [1].');
+ expect(html).not.toContain('other.example">1');
+ expect(html).toContain('https://example.com/1');
+ });
+
+ it('scopes reserved citation labels to the part that generated them', () => {
+ const { result } = renderHook(() =>
+ useCopyToClipboard({
+ content: [
+ { type: ContentTypes.TEXT, text: 'Cited \ue202turn0search0.' },
+ {
+ type: ContentTypes.TEXT,
+ text: 'See [manual][1].\n\n[1]: https://manual.example',
+ },
+ ] as TMessageContentParts[],
+ searchResults: {
+ '0': { organic: [{ link: 'https://example.com/1', title: 'Source 1' }] },
+ } as { [key: string]: SearchResultData },
+ richText: { variant: 'full', latex: false },
+ }),
+ );
+
+ act(() => {
+ result.current(mockSetIsCopied);
+ });
+
+ const html = getClipboardHtml();
+ expect(html).toContain('Cited [1].
');
+ expect(html).toContain('manual');
+ });
+
+ it('keeps the citation footer out of an unfinished construct', () => {
+ const { result } = renderHook(() =>
+ useCopyToClipboard({
+ text: 'Cited \ue202turn0search0.\n\n```js\nconst a = 1;',
+ searchResults: {
+ '0': { organic: [{ link: 'https://example.com/1', title: 'Source 1' }] },
+ } as { [key: string]: SearchResultData },
+ richText: { variant: 'full', latex: false },
+ }),
+ );
+
+ act(() => {
+ result.current(mockSetIsCopied);
+ });
+
+ const html = getClipboardHtml();
+ expect(html).toContain(
+ 'Citations:
[1] https://example.com/1
',
+ );
+ expect(html).toContain('const a = 1;');
+ expect(html).not.toContain('Citations:');
+ });
+
+ it('keeps the part boundary out of the plain text', () => {
+ const { result } = renderHook(() =>
+ useCopyToClipboard({
+ content: [
+ { type: ContentTypes.TEXT, text: 'First line' },
+ { type: ContentTypes.TEXT, text: 'Second line' },
+ ] as TMessageContentParts[],
+ searchResults: {
+ '0': { organic: [{ link: 'https://example.com/1', title: 'Source 1' }] },
+ } as { [key: string]: SearchResultData },
+ richText: { variant: 'full', latex: false },
+ }),
+ );
+
+ act(() => {
+ result.current(mockSetIsCopied);
+ });
+
+ const [plainText] = mockCopy.mock.calls[0];
+ expect(plainText).toBe('First line\nSecond line');
+ expect(plainText).not.toContain('\ue210');
+ });
+ });
});
diff --git a/client/src/hooks/Messages/useCopyToClipboard.ts b/client/src/hooks/Messages/useCopyToClipboard.ts
index f2d186c0f4..ca68595e07 100644
--- a/client/src/hooks/Messages/useCopyToClipboard.ts
+++ b/client/src/hooks/Messages/useCopyToClipboard.ts
@@ -1,7 +1,9 @@
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import copy from 'copy-to-clipboard';
+import { useRecoilValue } from 'recoil';
import { ContentTypes, SearchResultData } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
+import type { MarkdownVariant, RichTextMode } from '~/utils/richtext';
import {
SPAN_REGEX,
CLEANUP_REGEX,
@@ -9,6 +11,8 @@ import {
STANDALONE_PATTERN,
INVALID_CITATION_REGEX,
} from '~/utils/citations';
+import { markdownToHtml } from '~/utils/richtext';
+import store from '~/store';
type Source = {
link: string;
@@ -23,12 +27,20 @@ const refTypeMap: RecordSome bold and italic text.
', + ); + }); + + it('converts unordered and ordered lists', () => { + expect(markdownToHtml('- one\n- two')).toBe('| b | '); + expect(html).toContain('|
|---|---|
| 1 | '); + }); + + it('keeps code blocks and inline code monospaced', () => { + const html = markdownToHtml('Run `npm test`\n\n```js\nconst a = 1;\n```'); + + expect(html).toContain('b | ', + ); + }); +}); diff --git a/client/src/utils/richtext.ts b/client/src/utils/richtext.ts new file mode 100644 index 0000000000..e54269740b --- /dev/null +++ b/client/src/utils/richtext.ts @@ -0,0 +1,465 @@ +import supersub from 'remark-supersub'; +import { gfm } from 'micromark-extension-gfm'; +import { math } from 'micromark-extension-math'; +import { gfmFromMarkdown } from 'mdast-util-gfm'; +import { mathFromMarkdown } from 'mdast-util-math'; +import { defaultUrlTransform } from 'react-markdown'; +import { apiBaseUrl } from 'librechat-data-provider'; +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { directive } from 'micromark-extension-directive'; +import { directiveFromMarkdown } from 'mdast-util-directive'; +import type { + AlignType, + Definition, + FootnoteDefinition, + ListItem, + Root, + RootContent, + Table, +} from 'mdast'; +import type { Extension as MicromarkExtension } from 'micromark-util-types'; +import { mcpUIResourcePlugin } from '~/components/MCPUIResource/plugin'; +import { remarkApproxTilde } from './tilde'; +import { preprocessLaTeX } from './latex'; + +/** + * Which message renderer this copy has to match. `Markdown` (assistant turns) + * enables directives; `MarkdownLite` (user turns) does not, so a user message + * showing literal `:::` markers must keep them. + */ +export type MarkdownVariant = 'full' | 'lite'; + +export type RichTextMode = { + variant: MarkdownVariant; + /** The signed-in user, whose id identifies their own generated-file links. */ + userId?: string; + /** Whether `Markdown`'s LaTeX preprocessing is on, mirroring `LaTeXParsing`. */ + latex: boolean; + /** + * Reference labels that must not resolve against a definition. Citation + * markers are generated as `[1]`, `[2]`, and a message that also defines + * `[1]: https://elsewhere` would otherwise capture them. + */ + reserved?: ReadonlySet
${serializeChildren(node.children, context)}
`; + case 'heading': + return `${escapeHtml(node.value)}`;
+ case 'code':
+ return `${escapeHtml(node.value)}`;
+ case 'blockquote':
+ return `${serializeChildren( + node.children, + context, + )}`; + case 'list': { + const tag = node.ordered === true ? 'ol' : 'ul'; + const start = node.ordered === true && node.start != null && node.start !== 1; + let items = ''; + for (const item of node.children) { + items += serializeListItem(item, context, node.spread === true); + } + return `<${tag}${start ? ` start="${node.start}"` : ''}>${items}${tag}>`; + } + case 'listItem': + return serializeListItem(node, context, false); + case 'table': + return serializeTable(node, context); + case 'link': + return anchor(node.url, serializeChildren(node.children, context), context.userId); + case 'image': + return image(node.url, node.alt ?? ''); + case 'linkReference': { + const children = serializeChildren(node.children, context); + const definition = resolveReference(node, context); + return definition + ? anchor(definition.url, children, context.userId) + : revertReference(node, children, ''); + } + case 'imageReference': { + const alt = node.alt ?? ''; + const definition = resolveReference(node, context); + return definition ? image(definition.url, alt) : revertReference(node, escapeText(alt), '!'); + } + case 'thematicBreak': + return '
${escapeText(node.value)}
`; + case 'footnoteReference': + return `${context.footnotes.get(node.identifier) ?? ''}`; + case 'footnoteDefinition': { + const number = context.footnotes.get(node.identifier); + if (number == null) { + return ''; + } + return `