diff --git a/client/src/components/Chat/Messages/Content/SearchContent.tsx b/client/src/components/Chat/Messages/Content/SearchContent.tsx index 52a03262d0..bc68ede95d 100644 --- a/client/src/components/Chat/Messages/Content/SearchContent.tsx +++ b/client/src/components/Chat/Messages/Content/SearchContent.tsx @@ -18,6 +18,14 @@ import { AgentUpdate } from './Parts'; import store from '~/store'; import Part from './Part'; +/** + * Whether this message falls through to the unconditional `MarkdownLite` + * branch below, which renders markdown for either author regardless of + * `enableUserMsgMarkdown`. Callers that copy the message need the same answer. + */ +export const rendersMarkdownLite = (message: TMessage): boolean => + !Array.isArray(message.content) || message.content.length === 0; + const SearchContent = ({ message, attachments, diff --git a/client/src/components/Chat/Messages/Content/__tests__/SearchContent.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/SearchContent.spec.tsx new file mode 100644 index 0000000000..b342b65e9b --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/SearchContent.spec.tsx @@ -0,0 +1,21 @@ +import { ContentTypes } from 'librechat-data-provider'; +import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; +import { rendersMarkdownLite } from '~/components/Chat/Messages/Content/SearchContent'; + +describe('rendersMarkdownLite', () => { + const message = (content?: TMessageContentParts[]): TMessage => + ({ messageId: 'm', text: 'hi', content }) as TMessage; + + it('is true when there are no content parts to render', () => { + expect(rendersMarkdownLite(message())).toBe(true); + expect(rendersMarkdownLite(message([]))).toBe(true); + }); + + it('is false once content parts drive the rendering', () => { + expect( + rendersMarkdownLite( + message([{ type: ContentTypes.TEXT, text: 'hi' } as TMessageContentParts]), + ), + ).toBe(false); + }); +}); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 1afd543e44..60a4754e33 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -43,7 +43,7 @@ function MessageParts(props: TMessageProps) { copyToClipboard, getCanCopy, regenerateMessage, - } = useMessageHelpers(props); + } = useMessageHelpers(props, searchResults); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { messageId = null, isCreatedByUser } = message ?? {}; diff --git a/client/src/components/Chat/Messages/MinimalHoverButtons.tsx b/client/src/components/Chat/Messages/MinimalHoverButtons.tsx index 51d9175d3d..4fad744847 100644 --- a/client/src/components/Chat/Messages/MinimalHoverButtons.tsx +++ b/client/src/components/Chat/Messages/MinimalHoverButtons.tsx @@ -2,22 +2,28 @@ import { useState, useMemo } from 'react'; import { Copy, Check } from 'lucide'; import { Button, MorphIcon, TooltipAnchor } from '@librechat/client'; import type { TMessage, SearchResultData } from 'librechat-data-provider'; -import { useLocalize, useCopyToClipboard, hasCopyableText } from '~/hooks'; +import type { MarkdownVariant } from '~/utils/richtext'; +import { useLocalize, useCopyMessageToClipboard, hasCopyableText } from '~/hooks'; import { revealOnRowHoverClasses } from './styles'; import { cn } from '~/utils'; type THoverButtons = { message: TMessage; searchResults?: { [key: string]: SearchResultData }; + /** The renderer this row's message was displayed with, when it is not the authorship default. */ + variant?: MarkdownVariant; }; -export default function MinimalHoverButtons({ message, searchResults }: THoverButtons) { +export default function MinimalHoverButtons({ message, searchResults, variant }: THoverButtons) { const localize = useLocalize(); const [isCopied, setIsCopied] = useState(false); - const copyToClipboard = useCopyToClipboard({ + const copyToClipboard = useCopyMessageToClipboard({ text: message.text, content: message.content, searchResults, + isCreatedByUser: message.isCreatedByUser, + error: message.error, + variant, }); const canCopy = useMemo( () => hasCopyableText({ text: message.text, content: message.content, searchResults }), diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index cb42cb5074..6561dfd067 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -6,10 +6,10 @@ import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader' import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; +import SearchContent, { rendersMarkdownLite } from './Content/SearchContent'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import Icon from '~/components/Chat/Messages/MessageIcon'; import { useAuthContext, useLocalize } from '~/hooks'; -import SearchContent from './Content/SearchContent'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; import store from '~/store'; @@ -124,7 +124,10 @@ function SearchMessage({ message }: Pick) { className="final-completion" footer={ - + } diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 8da07921fa..ecd7f0b137 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -369,6 +369,19 @@ export const registry: SettingEntry[] = [ hoverCardText: 'com_nav_info_smooth_streaming', }), }, + { + id: 'copyRichText', + tab: CHAT, + section: 'messages', + labelKey: 'com_nav_copy_rich_text', + keywords: ['copy', 'clipboard', 'rich', 'html', 'format', 'markdown', 'paste'], + Component: toggleControl({ + stateAtom: store.copyRichText, + localizationKey: 'com_nav_copy_rich_text', + switchId: 'copyRichText', + hoverCardText: 'com_nav_info_copy_rich_text', + }), + }, { id: 'autoExpandTools', tab: CHAT, diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx index cfb8ba6e46..51d1225cc9 100644 --- a/client/src/components/Share/Message.tsx +++ b/client/src/components/Share/Message.tsx @@ -1,10 +1,12 @@ import type { TMessageProps } from '~/common'; +import SearchContent, { + rendersMarkdownLite, +} from '~/components/Chat/Messages/Content/SearchContent'; import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; -import SearchContent from '~/components/Chat/Messages/Content/SearchContent'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import SubRow from '~/components/Chat/Messages/SubRow'; @@ -70,7 +72,11 @@ export default function Message(props: TMessageProps) { siblingCount={siblingCount} setSiblingIdx={setSiblingIdx} /> - + } > diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index 7a0eb4d833..53e7dc0bfe 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -25,7 +25,7 @@ export { default as useMemoizedChatContext } from './useMemoizedChatContext'; export { default as useMessageProcess } from './useMessageProcess'; export { default as useMessageHelpers } from './useMessageHelpers'; export { default as useCopyToClipboard } from './useCopyToClipboard'; -export { hasCopyableText } from './useCopyToClipboard'; +export { hasCopyableText, useCopyMessageToClipboard } from './useCopyToClipboard'; export { default as useContentMetadata } from './useContentMetadata'; export { default as useMessageScrolling } from './useMessageScrolling'; export { default as useScrollbarGutter } from './useScrollbarGutter'; diff --git a/client/src/hooks/Messages/useCopyMessageToClipboard.spec.tsx b/client/src/hooks/Messages/useCopyMessageToClipboard.spec.tsx new file mode 100644 index 0000000000..c188ed7336 --- /dev/null +++ b/client/src/hooks/Messages/useCopyMessageToClipboard.spec.tsx @@ -0,0 +1,206 @@ +import { RecoilRoot } from 'recoil'; +import copy from 'copy-to-clipboard'; +import { renderHook, act } from '@testing-library/react'; +import type { MutableSnapshot } from 'recoil'; +import type { ReactNode } from 'react'; +import type { MarkdownVariant } from '~/utils/richtext'; +import { useCopyMessageToClipboard } from '~/hooks/Messages/useCopyToClipboard'; +import store from '~/store'; + +jest.mock('copy-to-clipboard'); + +describe('useCopyMessageToClipboard', () => { + const TEXT = '$E=mc^2$'; + const mockSetIsCopied = jest.fn(); + const mockCopy = copy as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockCopy.mockReturnValue(true); + }); + + const renderWithSettings = ( + settings: { copyRichText: boolean; enableUserMsgMarkdown: boolean; latexParsing?: boolean }, + isCreatedByUser: boolean, + variant?: MarkdownVariant, + error?: boolean, + ) => { + const initializeState = ({ set }: MutableSnapshot) => { + set(store.copyRichText, settings.copyRichText); + set(store.enableUserMsgMarkdown, settings.enableUserMsgMarkdown); + set(store.LaTeXParsing, settings.latexParsing ?? true); + }; + + return renderHook( + () => useCopyMessageToClipboard({ text: TEXT, isCreatedByUser, variant, error }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + }; + + const copiedAsHtml = (): boolean => { + const [, options] = mockCopy.mock.calls[0]; + return options?.onCopy != null; + }; + + const copiedHtml = (): string => { + const [, options] = mockCopy.mock.calls[0]; + const clipboardData = { setData: jest.fn() }; + options?.onCopy?.(clipboardData); + return (clipboardData.setData.mock.calls[0]?.[1] ?? '') as string; + }; + + it('copies an assistant message as html when the preference is on', () => { + const { result } = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: false }, + false, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(true); + }); + + it('keeps a user message plain when its markdown rendering is off', () => { + const { result } = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: false }, + true, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(false); + }); + + it('copies a user message as html once its markdown rendering is on', () => { + const { result } = renderWithSettings( + { copyRichText: true, enableUserMsgMarkdown: true }, + true, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedAsHtml()).toBe(true); + }); + + it('mirrors MarkdownLite for a user message, which has no directives', () => { + const initializeState = ({ set }: MutableSnapshot) => { + set(store.copyRichText, true); + set(store.enableUserMsgMarkdown, true); + }; + const { result } = renderHook( + () => useCopyMessageToClipboard({ text: ':::warning\ntext\n:::', isCreatedByUser: true }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedHtml()).toBe('

:::warning
text
:::

'); + }); + + it('mirrors Markdown for an assistant message, which unwraps directives', () => { + const initializeState = ({ set }: MutableSnapshot) => { + set(store.copyRichText, true); + set(store.enableUserMsgMarkdown, true); + }; + const { result } = renderHook( + () => useCopyMessageToClipboard({ text: ':::warning\ntext\n:::', isCreatedByUser: false }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + expect(copiedHtml()).toBe('

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('

Title

\n

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('

Findings

'); + expect(html).toContain('Cited [1]'); + expect(html).toContain('https://example.com/1'); + }); + + it('does not let a markdown construct span two content parts', () => { + const { result } = renderHook(() => + useCopyToClipboard({ + content: [ + { type: ContentTypes.TEXT, text: '```js' }, + { type: ContentTypes.TEXT, text: 'Prose that renders on its own.' }, + ] as TMessageContentParts[], + 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: Record = { search: 'organic', ref: 'references', news: 'topStories', + /** File-search sources share the `references` collection, as `useCitation` reads them. */ + file: 'references', image: 'images', video: 'videos', }; type ClipboardSource = Partial> & { searchResults?: { [key: string]: SearchResultData }; + /** + * Also place the markdown rendered as HTML on the clipboard, for paste + * targets that ignore Markdown. The mode selects the message renderer to + * mirror; omitting it copies plain text only. + */ + richText?: RichTextMode; }; function getPartText(part: TMessageContentParts): string { @@ -39,12 +51,10 @@ function getPartText(part: TMessageContentParts): string { return typeof part.text === 'string' ? part.text : (part.text?.value ?? ''); } -export function serializeMessageForClipboard({ - text, - content, -}: Partial>): string { +function getMessageParts({ text, content }: Partial>): string[] { if (!Array.isArray(content) || content.length === 0) { - return text ?? ''; + const messageText = text ?? ''; + return messageText.length > 0 ? [messageText] : []; } const parts: string[] = []; @@ -55,37 +65,116 @@ export function serializeMessageForClipboard({ } } - return parts.join('\n'); + return parts; } -function buildClipboardText({ text, content, searchResults }: ClipboardSource): string { - const messageText = serializeMessageForClipboard({ text, content }); +export function serializeMessageForClipboard( + source: Partial>, +): string { + return getMessageParts(source).join('\n'); +} + +type CitationSegment = { + text: string; + /** + * Generated citation markers in this segment, which must not resolve as + * markdown references. Scoped per segment: each is its own document, so a + * legitimate `[1]` reference in one part is unaffected by a citation numbered + * `1` in another. + */ + reserved: ReadonlySet; +}; + +/** One entry per rendered text part, plus the citation footer when there is one. */ +type ClipboardContent = CitationSegment[]; + +function buildClipboardContent({ + text, + content, + searchResults, +}: ClipboardSource): ClipboardContent { + const parts = getMessageParts({ text, content }); if (!searchResults || Object.keys(searchResults).length === 0) { - return messageText.replace(INVALID_CITATION_REGEX, '').replace(CLEANUP_REGEX, ''); + return parts.map((part) => ({ + text: part.replace(INVALID_CITATION_REGEX, '').replace(CLEANUP_REGEX, ''), + reserved: EMPTY_RESERVED, + })); } - const citationManager = processCitations(messageText, searchResults); + const processor = createCitationProcessor(searchResults); + const segments = parts.map((part) => processor.process(part)); - if (citationManager.citations.size === 0) { - return citationManager.formattedText; + if (processor.citations.size === 0) { + return segments; } - const sortedCitations = Array.from(citationManager.citations.entries()).sort( + const sortedCitations = Array.from(processor.citations.entries()).sort( (a, b) => a[1].referenceNumber - b[1].referenceNumber, ); const citationList = sortedCitations .map(([, citation]) => `[${citation.referenceNumber}] ${citation.link}\n`) .join(''); - return `${citationManager.formattedText}\n\nCitations:\n${citationList}`; + /** + * The footer is its own segment rather than an addition to the last one: a + * segment ending in an unclosed construct, an interrupted code fence most of + * all, would otherwise swallow the sources into it. + */ + segments.push({ + text: `\nCitations:\n${citationList}`, + reserved: new Set(sortedCitations.map(([, citation]) => `${citation.referenceNumber}`)), + }); + + return segments; +} + +function buildClipboardText(source: ClipboardSource): string { + return buildClipboardContent(source) + .map((segment) => segment.text) + .join('\n'); } export function hasCopyableText(source: ClipboardSource): boolean { return buildClipboardText(source).trim().length > 0; } -export default function useCopyToClipboard({ text, content, searchResults }: ClipboardSource) { +const EMPTY_RESERVED: ReadonlySet = new Set(); + +function buildCopyOptions( + segments: ClipboardContent, + richText: RichTextMode | undefined, +): Parameters[1] { + if (!richText) { + return { format: 'text/plain' }; + } + + let html = ''; + for (const segment of segments) { + const serialized = markdownToHtml(segment.text, { ...richText, reserved: segment.reserved }); + if (serialized.length > 0) { + html += html.length > 0 ? `\n${serialized}` : serialized; + } + } + + if (html.length === 0) { + return { format: 'text/plain' }; + } + + return { + format: 'text/plain', + onCopy: (clipboardData) => { + (clipboardData as DataTransfer | null)?.setData('text/html', html); + }, + }; +} + +export default function useCopyToClipboard({ + text, + content, + searchResults, + richText, +}: ClipboardSource) { const copyTimeoutRef = useRef(null); useEffect(() => { @@ -98,13 +187,14 @@ export default function useCopyToClipboard({ text, content, searchResults }: Cli const copyToClipboard = useCallback( (setIsCopied: React.Dispatch>): boolean => { - const clipboardText = buildClipboardText({ text, content, searchResults }); + const clipboardContent = buildClipboardContent({ text, content, searchResults }); + const clipboardText = clipboardContent.map((segment) => segment.text).join('\n'); if (clipboardText.trim().length === 0) { return false; } - if (!copy(clipboardText, { format: 'text/plain' })) { + if (!copy(clipboardText, buildCopyOptions(clipboardContent, richText))) { return false; } @@ -119,16 +209,74 @@ export default function useCopyToClipboard({ text, content, searchResults }: Cli return true; }, - [text, content, searchResults], + [text, content, searchResults, richText], ); return copyToClipboard; } +type MessageClipboardSource = ClipboardSource & + Partial> & { + /** Set by callers that render through a fixed renderer rather than the authorship default. */ + variant?: MarkdownVariant; + }; + +/** + * Copies a message honoring the user's rich text preference. Kept apart from + * `useCopyToClipboard` so the plain copies (share links, API keys, callback + * URLs) never get wrapped in markup. + * + * A user message is only rendered as markdown when `enableUserMsgMarkdown` is + * on, so with it off the HTML flavor is skipped: otherwise text that reads as + * literal on screen would arrive formatted in the paste target. User turns that + * do render go through `MarkdownLite`, which neither enables directives nor + * preprocesses LaTeX, hence the lighter mode. + */ +export function useCopyMessageToClipboard({ + isCreatedByUser, + error, + variant, + ...source +}: MessageClipboardSource) { + const copyRichText = useRecoilValue(store.copyRichText); + const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const latexParsing = useRecoilValue(store.LaTeXParsing); + const user = useRecoilValue(store.user); + + const richText = useMemo((): RichTextMode | undefined => { + /** An errored row is rendered by `ErrorMessage`, not as markdown at all. */ + if (!copyRichText || error === true) { + return undefined; + } + if (variant === 'lite') { + return { variant: 'lite', latex: false, userId: user?.id }; + } + if (variant === 'full' || isCreatedByUser !== true) { + return { variant: 'full', latex: latexParsing, userId: user?.id }; + } + return enableUserMsgMarkdown ? { variant: 'lite', latex: false, userId: user?.id } : undefined; + }, [ + copyRichText, + enableUserMsgMarkdown, + error, + isCreatedByUser, + latexParsing, + user?.id, + variant, + ]); + + return useCopyToClipboard({ ...source, richText }); +} + /** * Process citations in the text and format them properly */ -function processCitations(text: string, searchResults: { [key: string]: SearchResultData }) { +/** + * Citation numbering is shared across every text part of a message, while each + * part is formatted on its own: on screen each renders through its own Markdown + * instance, so a construct must not span two of them. + */ +function createCitationProcessor(searchResults: { [key: string]: SearchResultData }) { // Maps citation keys to their info including reference numbers const citations = new Map< string, @@ -144,219 +292,227 @@ function processCitations(text: string, searchResults: { [key: string]: SearchRe const urlToCitationKey = new Map(); let nextReferenceNumber = 1; - let formattedText = text; - // Step 1: Process highlighted text first (simplify by just making it bold in markdown) - formattedText = formattedText.replace(SPAN_REGEX, (match) => { - const text = match.replace(/\\ue203|\\ue204|\ue203|\ue204/g, ''); - return `**${text}**`; - }); + const process = (text: string): CitationSegment => { + /** Markers emitted into this text, which must not resolve as references. */ + const reserved = new Set(); + let formattedText = text; - // Step 2: Find all standalone citations and composite citation blocks - const allCitations: Array<{ - turn: string; - type: string; - index: string; - position: number; - fullMatch: string; - isComposite: boolean; - }> = []; - - // Find standalone citations - let standaloneMatch: RegExpExecArray | null; - const standaloneCopy = new RegExp(STANDALONE_PATTERN.source, 'g'); - while ((standaloneMatch = standaloneCopy.exec(formattedText)) !== null) { - allCitations.push({ - turn: standaloneMatch[1], - type: standaloneMatch[2], - index: standaloneMatch[3], - position: standaloneMatch.index, - fullMatch: standaloneMatch[0], - isComposite: false, + // Step 1: Process highlighted text first (simplify by just making it bold in markdown) + formattedText = formattedText.replace(SPAN_REGEX, (match) => { + const text = match.replace(/\\ue203|\\ue204|\ue203|\ue204/g, ''); + return `**${text}**`; }); - } - // Find composite citation blocks - let compositeMatch: RegExpExecArray | null; - const compositeCopy = new RegExp(COMPOSITE_REGEX.source, 'g'); - while ((compositeMatch = compositeCopy.exec(formattedText)) !== null) { - const block = compositeMatch[0]; - const blockStart = compositeMatch.index; + // Step 2: Find all standalone citations and composite citation blocks + const allCitations: Array<{ + turn: string; + type: string; + index: string; + position: number; + fullMatch: string; + isComposite: boolean; + }> = []; - // Extract individual citations within the composite block - let citationMatch: RegExpExecArray | null; - const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); - while ((citationMatch = citationPattern.exec(block)) !== null) { + // Find standalone citations + let standaloneMatch: RegExpExecArray | null; + const standaloneCopy = new RegExp(STANDALONE_PATTERN.source, 'g'); + while ((standaloneMatch = standaloneCopy.exec(formattedText)) !== null) { allCitations.push({ - turn: citationMatch[1], - type: citationMatch[2], - index: citationMatch[3], - position: blockStart + citationMatch.index, - fullMatch: block, // Store the full composite block - isComposite: true, + turn: standaloneMatch[1], + type: standaloneMatch[2], + index: standaloneMatch[3], + position: standaloneMatch.index, + fullMatch: standaloneMatch[0], + isComposite: false, }); } - } - // Sort citations by their position in the text - allCitations.sort((a, b) => a.position - b.position); + // Find composite citation blocks + let compositeMatch: RegExpExecArray | null; + const compositeCopy = new RegExp(COMPOSITE_REGEX.source, 'g'); + while ((compositeMatch = compositeCopy.exec(formattedText)) !== null) { + const block = compositeMatch[0]; + const blockStart = compositeMatch.index; - // Step 3: Process each citation and build the reference mapping - const processedCitations = new Set(); - const replacements: Array<[string, string]> = []; - const compositeCitationsMap = new Map(); - - for (const citation of allCitations) { - const { turn, type, index, fullMatch, isComposite } = citation; - const searchData = searchResults[turn]; - - if (!searchData) continue; - - const dataType = refTypeMap[type.toLowerCase()] || type.toLowerCase(); - const idx = parseInt(index, 10); - - // Skip if no matching data - if (!searchData[dataType] || !searchData[dataType][idx]) { - continue; - } - - // Get source data - const sourceData = searchData[dataType][idx]; - const sourceUrl = sourceData.link || ''; - - // Skip if no link - if (!sourceUrl) continue; - - // Check if this URL has already been cited - let citationKey = urlToCitationKey.get(sourceUrl); - - // If not, create a new citation key - if (!citationKey) { - citationKey = `${turn}-${dataType}-${idx}`; - urlToCitationKey.set(sourceUrl, citationKey); - } - - const source: Source = { - link: sourceUrl, - title: sourceData.title || sourceData.name || '', - attribution: sourceData.attribution || sourceData.source || '', - type: dataType, - typeIndex: idx, - citationKey, - }; - - // Skip if already processed this citation in a composite block - if (isComposite && processedCitations.has(fullMatch)) { - continue; - } - - let referenceText = ''; - - // Check if this source has been cited before - let existingCitation = citations.get(citationKey); - - if (!existingCitation) { - // New citation - existingCitation = { - referenceNumber: nextReferenceNumber++, - link: source.link, - title: source.title, - source, - }; - citations.set(citationKey, existingCitation); - } - - if (existingCitation) { - // For composite blocks, we need to find all citations and create a combined reference - if (isComposite) { - // Parse all citations in this composite block if we haven't processed it yet - if (!processedCitations.has(fullMatch)) { - const compositeCitations: number[] = []; - let citationMatch: RegExpExecArray | null; - const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); - - while ((citationMatch = citationPattern.exec(fullMatch)) !== null) { - const cTurn = citationMatch[1]; - const cType = citationMatch[2]; - const cIndex = citationMatch[3]; - const cDataType = refTypeMap[cType.toLowerCase()] || cType.toLowerCase(); - - const cSource = searchResults[cTurn]?.[cDataType]?.[parseInt(cIndex, 10)]; - if (cSource && cSource.link) { - // Check if we've already created a citation for this URL - const cUrl = cSource.link; - let cKey = urlToCitationKey.get(cUrl); - - if (!cKey) { - cKey = `${cTurn}-${cDataType}-${cIndex}`; - urlToCitationKey.set(cUrl, cKey); - } - - let cCitation = citations.get(cKey); - - if (!cCitation) { - cCitation = { - referenceNumber: nextReferenceNumber++, - link: cSource.link, - title: cSource.title || cSource.name || '', - source: { - link: cSource.link, - title: cSource.title || cSource.name || '', - attribution: cSource.attribution || cSource.source || '', - type: cDataType, - typeIndex: parseInt(cIndex, 10), - citationKey: cKey, - }, - }; - citations.set(cKey, cCitation); - } - - if (cCitation) { - compositeCitations.push(cCitation.referenceNumber); - } - } - } - - // Sort and deduplicate the composite citations - const uniqueSortedCitations = [...new Set(compositeCitations)].sort((a, b) => a - b); - - // Create combined reference numbers for all citations in this composite - referenceText = - uniqueSortedCitations.length > 0 - ? uniqueSortedCitations.map((num) => `[${num}]`).join('') - : ''; - - processedCitations.add(fullMatch); - compositeCitationsMap.set(fullMatch, uniqueSortedCitations); - replacements.push([fullMatch, referenceText]); - } - - // Skip further processing since we've handled the entire composite block - continue; - } else { - // Single citation - referenceText = `[${existingCitation.referenceNumber}]`; - replacements.push([fullMatch, referenceText]); + // Extract individual citations within the composite block + let citationMatch: RegExpExecArray | null; + const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); + while ((citationMatch = citationPattern.exec(block)) !== null) { + allCitations.push({ + turn: citationMatch[1], + type: citationMatch[2], + index: citationMatch[3], + position: blockStart + citationMatch.index, + fullMatch: block, // Store the full composite block + isComposite: true, + }); } } - } - // Step 4: Apply all replacements (from longest to shortest to avoid nested replacement issues) - replacements.sort((a, b) => b[0].length - a[0].length); - for (const [pattern, replacement] of replacements) { - formattedText = formattedText.replace(pattern, replacement); - } + // Sort citations by their position in the text + allCitations.sort((a, b) => a.position - b.position); - // Step 5: Remove any orphaned composite blocks at the end of the text - // This prevents the [1][2][3][4] list that might appear at the end if there's a composite there - formattedText = formattedText.replace(/\n\s*\[\d+\](\[\d+\])*\s*$/g, ''); + // Step 3: Process each citation and build the reference mapping + const processedCitations = new Set(); + const replacements: Array<[string, string]> = []; + const compositeCitationsMap = new Map(); - // Step 6: Clean up any remaining citation markers - formattedText = formattedText.replace(INVALID_CITATION_REGEX, ''); - formattedText = formattedText.replace(CLEANUP_REGEX, ''); + for (const citation of allCitations) { + const { turn, type, index, fullMatch, isComposite } = citation; + const searchData = searchResults[turn]; - return { - formattedText, - citations, + if (!searchData) continue; + + const dataType = refTypeMap[type.toLowerCase()] || type.toLowerCase(); + const idx = parseInt(index, 10); + + // Skip if no matching data + if (!searchData[dataType] || !searchData[dataType][idx]) { + continue; + } + + // Get source data + const sourceData = searchData[dataType][idx]; + const sourceUrl = sourceData.link || ''; + + // Skip if no link + if (!sourceUrl) continue; + + // Check if this URL has already been cited + let citationKey = urlToCitationKey.get(sourceUrl); + + // If not, create a new citation key + if (!citationKey) { + citationKey = `${turn}-${dataType}-${idx}`; + urlToCitationKey.set(sourceUrl, citationKey); + } + + const source: Source = { + link: sourceUrl, + title: sourceData.title || sourceData.name || '', + attribution: sourceData.attribution || sourceData.source || '', + type: dataType, + typeIndex: idx, + citationKey, + }; + + // Skip if already processed this citation in a composite block + if (isComposite && processedCitations.has(fullMatch)) { + continue; + } + + let referenceText = ''; + + // Check if this source has been cited before + let existingCitation = citations.get(citationKey); + + if (!existingCitation) { + // New citation + existingCitation = { + referenceNumber: nextReferenceNumber++, + link: source.link, + title: source.title, + source, + }; + citations.set(citationKey, existingCitation); + } + + if (existingCitation) { + // For composite blocks, we need to find all citations and create a combined reference + if (isComposite) { + // Parse all citations in this composite block if we haven't processed it yet + if (!processedCitations.has(fullMatch)) { + const compositeCitations: number[] = []; + let citationMatch: RegExpExecArray | null; + const citationPattern = new RegExp(STANDALONE_PATTERN.source, 'g'); + + while ((citationMatch = citationPattern.exec(fullMatch)) !== null) { + const cTurn = citationMatch[1]; + const cType = citationMatch[2]; + const cIndex = citationMatch[3]; + const cDataType = refTypeMap[cType.toLowerCase()] || cType.toLowerCase(); + + const cSource = searchResults[cTurn]?.[cDataType]?.[parseInt(cIndex, 10)]; + if (cSource && cSource.link) { + // Check if we've already created a citation for this URL + const cUrl = cSource.link; + let cKey = urlToCitationKey.get(cUrl); + + if (!cKey) { + cKey = `${cTurn}-${cDataType}-${cIndex}`; + urlToCitationKey.set(cUrl, cKey); + } + + let cCitation = citations.get(cKey); + + if (!cCitation) { + cCitation = { + referenceNumber: nextReferenceNumber++, + link: cSource.link, + title: cSource.title || cSource.name || '', + source: { + link: cSource.link, + title: cSource.title || cSource.name || '', + attribution: cSource.attribution || cSource.source || '', + type: cDataType, + typeIndex: parseInt(cIndex, 10), + citationKey: cKey, + }, + }; + citations.set(cKey, cCitation); + } + + if (cCitation) { + compositeCitations.push(cCitation.referenceNumber); + } + } + } + + // Sort and deduplicate the composite citations + const uniqueSortedCitations = [...new Set(compositeCitations)].sort((a, b) => a - b); + + // Create combined reference numbers for all citations in this composite + for (const num of uniqueSortedCitations) { + reserved.add(`${num}`); + } + referenceText = + uniqueSortedCitations.length > 0 + ? uniqueSortedCitations.map((num) => `[${num}]`).join('') + : ''; + + processedCitations.add(fullMatch); + compositeCitationsMap.set(fullMatch, uniqueSortedCitations); + replacements.push([fullMatch, referenceText]); + } + + // Skip further processing since we've handled the entire composite block + continue; + } else { + // Single citation + reserved.add(`${existingCitation.referenceNumber}`); + referenceText = `[${existingCitation.referenceNumber}]`; + replacements.push([fullMatch, referenceText]); + } + } + } + + // Step 4: Apply all replacements (from longest to shortest to avoid nested replacement issues) + replacements.sort((a, b) => b[0].length - a[0].length); + for (const [pattern, replacement] of replacements) { + formattedText = formattedText.replace(pattern, replacement); + } + + // Step 5: Remove any orphaned composite blocks at the end of the text + // This prevents the [1][2][3][4] list that might appear at the end if there's a composite there + formattedText = formattedText.replace(/\n\s*\[\d+\](\[\d+\])*\s*$/g, ''); + + // Step 6: Clean up any remaining citation markers + formattedText = formattedText.replace(INVALID_CITATION_REGEX, ''); + formattedText = formattedText.replace(CLEANUP_REGEX, ''); + + return { text: formattedText, reserved }; }; + + return { process, citations }; } diff --git a/client/src/hooks/Messages/useMessageActions.tsx b/client/src/hooks/Messages/useMessageActions.tsx index a5b67b0468..7360088fac 100644 --- a/client/src/hooks/Messages/useMessageActions.tsx +++ b/client/src/hooks/Messages/useMessageActions.tsx @@ -12,8 +12,8 @@ import { } from 'librechat-data-provider'; import type { TMessageChatContext } from '~/common/types'; import type { TMessageProps } from '~/common'; +import { useCopyMessageToClipboard, hasCopyableText } from './useCopyToClipboard'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; -import useCopyToClipboard, { hasCopyableText } from './useCopyToClipboard'; import { useAuthContext } from '~/hooks/AuthContext'; import { useGetAddedConvo } from '~/hooks/Chat'; import { useLocalize } from '~/hooks'; @@ -122,7 +122,13 @@ export default function useMessageActions(props: TMessageActions) { regenerate(message, { addedConvo: getAddedConvo() }); }, [chatContext, isCreatedByUser, message, regenerate, getAddedConvo]); - const copyToClipboard = useCopyToClipboard({ text, content, searchResults }); + const copyToClipboard = useCopyMessageToClipboard({ + text, + content, + searchResults, + isCreatedByUser, + error: message?.error, + }); const getCanCopy = useCallback( () => hasCopyableText({ text, content, searchResults }), diff --git a/client/src/hooks/Messages/useMessageHelpers.tsx b/client/src/hooks/Messages/useMessageHelpers.tsx index 3d85cd9388..66a4a026e4 100644 --- a/client/src/hooks/Messages/useMessageHelpers.tsx +++ b/client/src/hooks/Messages/useMessageHelpers.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from 'react'; import throttle from 'lodash/throttle'; import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import type { SearchResultData } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; -import useCopyToClipboard, { hasCopyableText } from './useCopyToClipboard'; +import { useCopyMessageToClipboard, hasCopyableText } from './useCopyToClipboard'; import { useGetAddedConvo } from '~/hooks/Chat'; import { logger } from '~/utils'; -export default function useMessageHelpers(props: TMessageProps) { +export default function useMessageHelpers( + props: TMessageProps, + searchResults?: { [key: string]: SearchResultData }, +) { const { message, currentEditId, setCurrentEditId } = props; const { @@ -81,9 +85,18 @@ export default function useMessageHelpers(props: TMessageProps) { regenerate(message, { addedConvo: getAddedConvo() }); }; - const copyToClipboard = useCopyToClipboard({ text, content }); + const copyToClipboard = useCopyMessageToClipboard({ + text, + content, + searchResults, + isCreatedByUser, + error: message?.error, + }); - const getCanCopy = useCallback(() => hasCopyableText({ text, content }), [text, content]); + const getCanCopy = useCallback( + () => hasCopyableText({ text, content, searchResults }), + [text, content, searchResults], + ); return { ask, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b53665905b..2d599a3aad 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -498,6 +498,7 @@ "com_nav_control_panel": "Control Panel", "com_nav_conversation_mode": "Conversation Mode", "com_nav_convo_menu_options": "Conversation Menu Options", + "com_nav_copy_rich_text": "Copy messages as rich text", "com_nav_db_sensitivity": "Decibel sensitivity", "com_nav_default_temporary_chat": "Temporary Chat by default", "com_nav_delete_account": "Delete account", @@ -542,6 +543,7 @@ "com_nav_info_client_image_resize_unsupported": "Image resizing is unavailable because this browser does not support the required image processing features.", "com_nav_info_code_background": "When enabled, the model can run code executions in the background: the conversation continues immediately while the code runs, and the model retrieves the result later with the background task tool. Output and generated files still attach to the original code run. Requires the app-level background tools capability.", "com_nav_info_collapse_user_messages": "When enabled, long user messages render collapsed to a preview with a Show more option, so pasted text or code cannot dominate the conversation. The full text stays in the page, ready to copy or search.", + "com_nav_info_copy_rich_text": "When enabled, copying a message also places a formatted version on the clipboard, so headings, lists, tables and code keep their styling when pasted into apps that do not support Markdown, such as Teams, Outlook or Word.", "com_nav_info_default_temporary_chat": "When enabled, new chats will start with temporary chat mode activated by default. Temporary chats are not saved to your history.", "com_nav_info_during_run_action": "Choose what pressing Enter does while the assistant is still responding. \"Steer the response\" inserts your message into the current response at its next step; \"Queue for after\" sends it as a new turn once the response finishes. You can always override this per message from the send button.", "com_nav_info_enter_to_send": "When enabled, pressing `ENTER` will send your message. When disabled, pressing Enter will add a new line, and you'll need to press `CTRL + ENTER` / `⌘ + ENTER` to send your message.", diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index c0bb927128..90f293b2f5 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -97,6 +97,12 @@ const localStorageAtoms = { saveBadgesState: atomWithLocalStorage('saveBadgesState', false), /** User preference for downscaling images before upload; ignored when the admin config sets it */ clientImageResize: atomWithLocalStorage('clientImageResize', false), + /** + * Whether copying a message also puts a rendered HTML version on the + * clipboard, so apps that ignore Markdown (Teams, Outlook, Word) paste the + * formatting instead of the raw syntax. + */ + copyRichText: atomWithLocalStorage('copyRichText', false), // Beta features settings modularChat: atomWithLocalStorage('modularChat', true), diff --git a/client/src/utils/richtext.spec.ts b/client/src/utils/richtext.spec.ts new file mode 100644 index 0000000000..7ca85bc184 --- /dev/null +++ b/client/src/utils/richtext.spec.ts @@ -0,0 +1,254 @@ +import { apiBaseUrl } from 'librechat-data-provider'; +import { markdownToHtml } from './richtext'; + +describe('markdownToHtml', () => { + it('returns an empty string for empty markdown', () => { + expect(markdownToHtml('')).toBe(''); + expect(markdownToHtml(' \n ')).toBe(''); + }); + + it('converts headings, emphasis and paragraphs', () => { + expect(markdownToHtml('# Title\n\nSome **bold** and *italic* text.')).toBe( + '

Title

\n

Some bold and italic text.

', + ); + }); + + it('converts unordered and ordered lists', () => { + expect(markdownToHtml('- one\n- two')).toBe('
  • one
  • two
'); + expect(markdownToHtml('3. three\n4. four')).toBe( + '
  1. three
  2. four
', + ); + }); + + it('marks task list items', () => { + expect(markdownToHtml('- [x] done\n- [ ] todo')).toBe( + '
  • ☑ done
  • ☐ todo
', + ); + }); + + it('styles tables so they keep their borders when pasted', () => { + const html = markdownToHtml('| a | b |\n| --- | ---: |\n| 1 | 2 |'); + + expect(html).toContain(''); + expect(html).toContain(''); + 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('const a = 1;'); + }); + + it('keeps safe links and drops unsafe ones', () => { + expect(markdownToHtml('[docs](https://example.com)')).toBe( + '

docs

', + ); + expect(markdownToHtml('[click](javascript:alert(1))')).toBe('

click

'); + }); + + it('escapes text, code and raw html instead of emitting markup', () => { + expect(markdownToHtml('5 < 6 & "quoted"')).toBe('

5 < 6 & "quoted"

'); + expect(markdownToHtml('')).toBe( + '<script>alert(1)</script>', + ); + expect(markdownToHtml('`x`')).toContain('<b>x</b>'); + }); + + it('converts blockquotes, rules, strikethrough and images', () => { + expect(markdownToHtml('> quoted')).toContain('

', + ); + }); + + it('renders math as its LaTeX source and unwraps directives', () => { + expect(markdownToHtml('$$E = mc^2$$')).toBe('

E = mc^2

'); + expect(markdownToHtml(':::note\ncontents\n:::')).toBe('

contents

'); + }); + + it('keeps directive markers literal for the lite renderer, which has no directives', () => { + expect(markdownToHtml(':::warning\ntext\n:::', { variant: 'lite', latex: false })).toBe( + '

:::warning
text
:::

', + ); + }); + + it('applies the supersub transform the renderers apply', () => { + expect(markdownToHtml('x^2^ is squared')).toBe('

x2 is squared

'); + expect(markdownToHtml('x^2^ is squared', { variant: 'lite', latex: false })).toBe( + '

x2 is squared

', + ); + }); + + it('leaves an approximate tilde out of the subscript pairing', () => { + expect(markdownToHtml('about ~50% of the time')).toBe('

about ∼50% of the time

'); + }); + + it('absolutizes relative links and images so they survive the paste', () => { + expect(markdownToHtml('[file](/api/files/code/download/abc)')).toBe( + `

file

`, + ); + expect(markdownToHtml('![chart](/images/chart.png)')).toBe( + `

chart

`, + ); + }); + + it('keeps soft line breaks visible, as the pre-wrap renderer does', () => { + expect(markdownToHtml('first line\nsecond line')).toBe('

first line
second line

'); + }); + + it('keeps bare relative links and images, resolved against the app', () => { + expect(markdownToHtml('[guide](docs/guide.html)')).toBe( + `

guide

`, + ); + expect(markdownToHtml('![plot](images/plot.png)')).toBe( + `

plot

`, + ); + }); + + it('renders single-dollar math when the renderer preprocesses LaTeX', () => { + expect(markdownToHtml('Energy is $E=mc^2$ inline.', { variant: 'full', latex: true })).toBe( + '

Energy is E=mc^2 inline.

', + ); + expect(markdownToHtml('Energy is $E=mc^2$ inline.')).toBe('

Energy is $E=mc^2$ inline.

'); + }); + + it('still leaves currency alone with LaTeX preprocessing on', () => { + expect(markdownToHtml('Costs rose from $5 to $10.', { variant: 'full', latex: true })).toBe( + '

Costs rose from $5 to $10.

', + ); + }); + + it('shows an inline directive by name, as the artifact plugin does', () => { + expect(markdownToHtml('a :foo[bar] b')).toBe('

a :foo b

'); + }); + + it('omits a footnote definition nothing references, as the renderer does', () => { + expect(markdownToHtml('Plain text.\n\n[^unused]: never cited')).toBe('

Plain text.

'); + }); + + it('keeps reserved labels from capturing a reference', () => { + const markdown = 'Cited [1].\n\n[1]: https://other.example'; + + expect(markdownToHtml(markdown)).toBe('

Cited 1.

'); + expect( + markdownToHtml(markdown, { variant: 'full', latex: false, reserved: new Set(['1']) }), + ).toBe('

Cited [1].

'); + }); + + it('keeps a loose list wrapped in paragraphs, as the renderer does', () => { + expect(markdownToHtml('- one\n\n- two')).toBe( + '
  • one

  • two

', + ); + expect(markdownToHtml('- one\n- two')).toBe('
  • one
  • two
'); + }); + + it('shows an artifact by its title, which is what the button displays', () => { + expect(markdownToHtml(':::artifact{title="Chart component"}\ncode here\n:::')).toBe( + '

Chart component

', + ); + }); + + it('falls back to the button default for an untitled artifact', () => { + expect(markdownToHtml('::artifact{identifier="a" type="x"}')).toBe('

untitled

'); + }); + + it('drops MCP resource markers rather than pasting the protocol text', () => { + expect(markdownToHtml('See \\ui{resource1} here.')).toBe('

See here.

'); + expect(markdownToHtml('See \\ui{a,b} here.')).toBe('

See here.

'); + }); + + it('leaves MCP markers alone for the lite renderer, which does not transform them', () => { + expect(markdownToHtml('See \\ui{resource1} here.', { variant: 'lite', latex: false })).toBe( + '

See \\ui{resource1} here.

', + ); + }); + + it('routes rooted image sources through the deployment base', () => { + const html = markdownToHtml('![chart](/images/chart.png)'); + expect(html).toBe( + `

chart

`, + ); + }); + + it('gathers footnote definitions into a footer in reference order', () => { + const html = markdownToHtml('Second[^b] then first[^a].\n\n[^a]: alpha\n\n[^b]: beta'); + + expect(html).toBe( + '

Second1 then first2.

\n' + + '
1

beta

\n' + + '
2

alpha

', + ); + }); + + it('keeps the first of two definitions sharing a label', () => { + expect( + markdownToHtml( + '[docs][guide]\n\n[guide]: https://first.example\n[guide]: https://second.example', + ), + ).toBe('

docs

'); + }); + + it('routes a generated file link through the app, as the renderer does', () => { + const url = 'https://provider.example/files/user-1/file-1/report.csv'; + const mode = { variant: 'full' as const, latex: false, userId: 'user-1' }; + + expect(markdownToHtml(`[report](${url})`, mode)).toBe( + `

report

`, + ); + }); + + it("leaves another user's file link untouched", () => { + const url = 'https://provider.example/files/user-2/file-1/report.csv'; + + expect( + markdownToHtml(`[report](${url})`, { variant: 'full', latex: false, userId: 'user-1' }), + ).toBe(`

report

`); + }); + + it('leaves paired currency alone, as the renderer does', () => { + expect(markdownToHtml('Costs rose from $5 to $10.')).toBe('

Costs rose from $5 to $10.

'); + }); + + it('resolves reference-style links and images against their definitions', () => { + expect(markdownToHtml('See [docs][guide].\n\n[guide]: https://example.com')).toBe( + '

See docs.

', + ); + expect(markdownToHtml('![logo][pic]\n\n[pic]: https://example.com/a.png')).toBe( + '

logo

', + ); + }); + + it('falls back to plain text when a reference has no definition', () => { + expect(markdownToHtml('See [docs][missing].')).toBe('

See [docs][missing].

'); + }); + + it('pairs every background it sets with its own foreground', () => { + const html = markdownToHtml('`x`\n\n```\ny\n```\n\n| a |\n| --- |\n| b |'); + const backgrounds = html.match(/background-color:[^;]+;/g) ?? []; + + expect(backgrounds.length).toBeGreaterThan(0); + for (const style of html.match(/style="[^"]*background-color[^"]*"/g) ?? []) { + expect(style).toContain('color:#24292f;'); + } + expect(backgrounds).toHaveLength( + (html.match(/style="[^"]*background-color[^"]*"/g) ?? []).length, + ); + }); + + it('lets blockquotes and table cells inherit the destination colors', () => { + expect(markdownToHtml('> quoted')).toContain( + '
', + ); + expect(markdownToHtml('| a |\n| --- |\n| b |')).toContain( + '
', + ); + }); +}); 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; +}; + +/** + * `remark-supersub` is typed as a unified `Transformer`, which declares a + * `file` and a `next` the plugin never reads: it only walks the tree. Narrowing + * those to optional arguments lets it be called as the plain mdast transform it + * is, so this copy stays on exactly the transform the renderers apply. + */ +const applySuperSub = supersub() as (tree: Root, file?: unknown, next?: unknown) => void; + +/** `remark-supersub` produces these outside of mdast's own node set. */ +type SuperSubNode = { + type: 'superscript' | 'subscript'; + children: SerializableNode[]; +}; + +/** `mcpUIResourcePlugin` produces these in place of its own protocol markers. */ +type McpResourceNode = { + type: 'mcp-ui-resource' | 'mcp-ui-carousel'; +}; + +type SerializableNode = RootContent | SuperSubNode | McpResourceNode; + +const MONOSPACE = 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace'; + +/** + * The paste target's theme is unknowable, so a background is only ever set + * together with its own foreground. Everything else inherits the destination's + * colors rather than assuming a light canvas. + */ +const CODE_COLORS = 'background-color:#f6f8fa;color:#24292f;'; +const BORDER = '1px solid #d0d7de'; + +const STYLES = { + inlineCode: `font-family:${MONOSPACE};${CODE_COLORS}border-radius:4px;padding:0.15em 0.35em;`, + codeBlock: `font-family:${MONOSPACE};${CODE_COLORS}border-radius:6px;padding:12px;white-space:pre-wrap;`, + blockquote: 'margin:0 0 16px;padding:0 1em;border-left:4px solid #d0d7de;', + table: 'border-collapse:collapse;', + headerCell: `border:${BORDER};padding:6px 13px;${CODE_COLORS}`, + cell: `border:${BORDER};padding:6px 13px;`, +} as const; + +const EMPTY_RESERVED: ReadonlySet = new Set(); + +/** + * `artifactPlugin` swaps this directive for a button showing the artifact's + * title, so the implementation inside it is never on screen to be copied. + */ +const ARTIFACT_DIRECTIVE = 'artifact'; + +/** `Artifact`'s own fallback when a directive carries no title. */ +const ARTIFACT_DEFAULT_TITLE = 'untitled'; + +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', +}; + +type SerializeContext = { + userId?: string; + definitions: Map; + /** Footnote label to its displayed number, in the order references appear. */ + footnotes: Map; + reserved: ReadonlySet; +}; + +const escapeHtml = (value: string): string => + value.replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]); + +/** + * Message text renders under `white-space: pre-wrap`, so a soft line break is a + * visible line on screen. HTML would collapse the newline to a space, which is + * what every paste target would then show. + */ +const escapeText = (value: string): string => escapeHtml(value).replace(/\n/g, '
'); + +const alignStyle = (align: AlignType | undefined): string => + align == null ? '' : `text-align:${align};`; + +const taskMarker = (checked: boolean | null | undefined): string => { + if (checked === true) { + return '☑ '; + } + return checked === false ? '☐ ' : ''; +}; + +function isAbsoluteUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * Screens the URL exactly as the renderer does, through react-markdown's own + * transform, then resolves what it keeps. A relative URL resolves against + * LibreChat on screen but against the destination document once pasted, so it + * has to leave absolute. Already-absolute URLs are passed through verbatim + * rather than normalized, which would rewrite what the user sees. + */ +function resolveUrl(url: string): string { + const transformed = defaultUrlTransform(url); + if (transformed.length === 0 || isAbsoluteUrl(transformed)) { + return transformed; + } + + try { + return new URL(transformed, document.baseURI).href; + } catch { + return transformed; + } +} + +const artifactTitle = (attributes: Record | null | undefined) => + `

${escapeText(attributes?.title || ARTIFACT_DEFAULT_TITLE)}

`; + +/** + * The renderer routes a user's own generated files through LibreChat's file + * endpoint rather than the provider URL the markdown carries, so the pasted + * link has to be the one the conversation actually points at. + */ +function generatedFileUrl(url: string, userId: string | undefined): string { + if (userId == null || userId.length === 0) { + return url; + } + + const match = url.match(new RegExp(`(?:files|outputs)/${userId}/([^\\s]+)`)); + const filepath = match?.[0] ?? ''; + if (filepath.length === 0) { + return url; + } + + const parts = filepath.split('/'); + const filename = parts.pop() ?? ''; + const fileId = parts.pop() ?? ''; + if (fileId.length === 0 || filename.length === 0) { + return url; + } + + const base = `${apiBaseUrl()}/api`; + return filepath.startsWith('files/') ? `${base}/${filepath}` : `${base}/files/${filepath}`; +} + +const anchor = (url: string, children: string, userId: string | undefined): string => { + const resolved = resolveUrl(generatedFileUrl(url, userId)); + return resolved.length > 0 ? `${children}` : children; +}; + +/** + * The renderer prepends the deployment base to rooted `/images/` sources, which + * a leading slash would otherwise discard on a subdirectory install. + */ +const IMAGE_ROOT = '/images/'; + +const image = (url: string, alt: string): string => { + const rooted = url.startsWith(IMAGE_ROOT) ? `${apiBaseUrl()}${url}` : url; + const resolved = resolveUrl(rooted); + return resolved.length > 0 + ? `${escapeHtml(alt)}` + : escapeHtml(alt); +}; + +/** + * Reference-style links resolve against definitions that may appear anywhere in + * the message, including after their use, and footnotes are numbered by the + * order their references appear, so both are collected before any node is + * serialized. + */ +function collectContext(nodes: readonly SerializableNode[], context: SerializeContext): void { + for (const node of nodes) { + if (node.type === 'definition') { + /** CommonMark gives precedence to the first definition of a label. */ + if (!context.definitions.has(node.identifier)) { + context.definitions.set(node.identifier, node); + } + continue; + } + if (node.type === 'footnoteReference' && !context.footnotes.has(node.identifier)) { + context.footnotes.set(node.identifier, context.footnotes.size + 1); + } + if ('children' in node) { + collectContext(node.children, context); + } + } +} + +type Reference = { + identifier: string; + label?: string | null; + referenceType: 'shortcut' | 'collapsed' | 'full'; +}; + +const resolveReference = (node: Reference, context: SerializeContext): Definition | undefined => + context.reserved.has(node.identifier) ? undefined : context.definitions.get(node.identifier); + +/** + * An unresolved reference falls back to its own source form, the way + * remark-rehype reverts one it cannot match. + */ +function revertReference(node: Reference, inner: string, prefix: string): string { + if (node.referenceType === 'full') { + return `${prefix}[${inner}][${escapeText(node.label ?? node.identifier)}]`; + } + if (node.referenceType === 'collapsed') { + return `${prefix}[${inner}][]`; + } + return `${prefix}[${inner}]`; +} + +function serializeTable(node: Table, context: SerializeContext): string { + const align = node.align ?? []; + const [headerRow, ...bodyRows] = node.children; + + const cells = (row: (typeof node.children)[number], tag: 'th' | 'td', style: string): string => { + let html = ''; + for (let index = 0; index < row.children.length; index++) { + const cell = row.children[index]; + html += `<${tag} style="${style}${alignStyle(align[index])}">${serializeChildren( + cell.children, + context, + )}`; + } + return `
${html}`; + }; + + const head = headerRow ? `${cells(headerRow, 'th', STYLES.headerCell)}` : ''; + + let body = ''; + for (const row of bodyRows) { + body += cells(row, 'td', STYLES.cell); + } + + return `
b
b
${head}${body && `${body}`}
`; +} + +/** + * Raw HTML is emitted as text because the message renderer does not enable + * `rehype-raw` either: what the user sees is the literal markup, and the + * clipboard copy must not smuggle live markup into the paste target. + */ +function serializeNode(node: SerializableNode, context: SerializeContext): string { + switch (node.type) { + case 'text': + case 'html': + return escapeText(node.value); + case 'paragraph': + return `

${serializeChildren(node.children, context)}

`; + case 'heading': + return `${serializeChildren(node.children, context)}`; + case 'strong': + return `${serializeChildren(node.children, context)}`; + case 'emphasis': + return `${serializeChildren(node.children, context)}`; + case 'delete': + return `${serializeChildren(node.children, context)}`; + case 'superscript': + return `${serializeChildren(node.children, context)}`; + case 'subscript': + return `${serializeChildren(node.children, context)}`; + case 'inlineCode': + 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}`; + } + 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 '
'; + case 'break': + return '
'; + case 'inlineMath': + return escapeText(node.value); + case 'math': + 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 `
${number}${serializeChildren(node.children, context)}
`; + } + case 'textDirective': + return escapeText(`:${node.name}`); + case 'containerDirective': + case 'leafDirective': + return node.name === ARTIFACT_DIRECTIVE + ? artifactTitle(node.attributes) + : serializeChildren(node.children, context); + case 'definition': + return ''; + /** An embedded, interactive resource has no static form to paste. */ + case 'mcp-ui-resource': + case 'mcp-ui-carousel': + return ''; + default: + return 'children' in node ? serializeChildren(node.children, context) : ''; + } +} + +/** + * A loose list keeps each item's paragraph wrapper, and mdast records that + * looseness on the list rather than on every item, so the parent decides. + */ +function serializeListItem(node: ListItem, context: SerializeContext, loose: boolean): string { + const [firstChild] = node.children; + const tight = !loose && node.spread !== true && firstChild?.type === 'paragraph'; + const children = tight + ? serializeChildren(firstChild.children, context) + + serializeChildren(node.children.slice(1), context) + : serializeChildren(node.children, context); + return `
  • ${taskMarker(node.checked)}${children}
  • `; +} + +function serializeChildren(nodes: readonly SerializableNode[], context: SerializeContext): string { + let html = ''; + for (const node of nodes) { + html += serializeNode(node, context); + } + return html; +} + +/** + * Convert message markdown into the semantic, inline-styled HTML that goes on + * the clipboard as `text/html`. Paste targets that ignore Markdown (Teams, + * Outlook, Word) strip stylesheets, so every visual cue has to be inline. + * + * Parsing mirrors the message renderers: the same LaTeX preprocessing, the same + * micromark extensions with `singleDollarTextMath` off so currency such as + * `$5 to $10` stays currency, then the same `remarkApproxTilde` and + * `remark-supersub` transforms they apply to the parsed tree. + */ +export function markdownToHtml( + markdown: string, + mode: RichTextMode = { variant: 'full', latex: false }, +): string { + const extensions: MicromarkExtension[] = [gfm(), math({ singleDollarTextMath: false })]; + if (mode.variant === 'full') { + extensions.push(directive()); + } + + const source = mode.latex ? preprocessLaTeX(markdown) : markdown; + + const tree = fromMarkdown(source, { + extensions, + mdastExtensions: [gfmFromMarkdown(), directiveFromMarkdown(), mathFromMarkdown()], + }); + + remarkApproxTilde()(tree); + applySuperSub(tree); + if (mode.variant === 'full') { + mcpUIResourcePlugin()(tree); + } + + const children = tree.children as SerializableNode[]; + const context: SerializeContext = { + userId: mode.userId, + definitions: new Map(), + footnotes: new Map(), + reserved: mode.reserved ?? EMPTY_RESERVED, + }; + collectContext(children, context); + + const blocks: string[] = []; + const notes: SerializableNode[] = []; + + for (const node of children) { + if (node.type === 'footnoteDefinition') { + notes.push(node); + continue; + } + blocks.push(serializeNode(node, context)); + } + + /** The renderer gathers footnotes into a footer ordered by first reference. */ + notes.sort( + (a, b) => + (context.footnotes.get((a as FootnoteDefinition).identifier) ?? 0) - + (context.footnotes.get((b as FootnoteDefinition).identifier) ?? 0), + ); + for (const note of notes) { + blocks.push(serializeNode(note, context)); + } + + return blocks.filter((block) => block.length > 0).join('\n'); +}