mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
📋 feat: Copy Messages as Rich Text (#15087)
* feat: copy messages as rich text Adds an opt-in setting that places a rendered HTML flavor on the clipboard next to the markdown, so pasting an assistant response into an app that does not understand Markdown (Teams, Outlook, Word) keeps its headings, lists, tables and code formatting. The HTML is serialized from the same mdast pipeline the message renderer already uses, with inline styles because those paste targets strip stylesheets. Raw HTML in a message is escaped rather than passed through, matching the renderer, and link and image URLs go through the existing isSafeUrl allowlist. The plain copies (share links, API keys, OAuth callback URLs) keep using the base hook, so they are never wrapped in markup. The setting is off by default: markdown-aware targets prefer the HTML flavor when it is present, so making it unconditional would regress them. Closes #13973 * fix: address review feedback on rich text clipboard copy Mirror the renderer's `singleDollarTextMath: false` so paired currency such as "$5 to $10" is not read as math and stripped of its dollar signs in the HTML flavor. Pair every background the serializer sets with its own foreground, and let blockquotes and table cells inherit the destination's colors. The paste target's theme is unknowable, so a lone light background could land under a dark editor's light text. Resolve reference-style links and images against their definitions rather than dropping the hyperlink. Skip the HTML flavor for user messages while `enableUserMsgMarkdown` is off. Those render as literal text on screen, so formatting them on paste would not match what was copied. * fix: mirror the message renderers more closely in rich text copies Apply the same `remarkApproxTilde` and `remark-supersub` transforms the renderers apply to the parsed tree, so `x^2^` arrives as a superscript instead of literal markers. Select the renderer to mirror per message: `Markdown` (assistant) enables directives, `MarkdownLite` (user) does not, so a user message showing literal `:::` markers keeps them in the HTML flavor. Resolve path-relative URLs against the app's base URI. A generated file or image link resolves against LibreChat on screen but against the destination document once pasted. Absolute URLs are passed through verbatim rather than normalized. Serialize each text content part as its own document. Each part renders through its own Markdown instance on screen, so a construct must not span two of them; a private-use sentinel carries the boundary through citation processing, which only touches its own markers. * fix: match the renderer on line breaks, relative URLs and LaTeX Message text renders under white-space: pre-wrap, so a soft line break is a visible line on screen. Emit those as <br /> instead of a raw newline, which every HTML paste target would collapse to a space. Screen URLs through react-markdown's own transform rather than the artifact template's isSafeUrl, which rejected bare relative targets like docs/guide.html and dropped links the conversation shows as working. Everything relative is then resolved against the app's base URI. Apply preprocessLaTeX to assistant segments when LaTeXParsing is on, as the assistant renderer does, so single-dollar math is read as math rather than left as literal dollar signs. * fix: align rich text copies with directives, footnotes and search rendering Show an inline directive by name, as the artifact plugin displays it, rather than dropping the marker and keeping only its children. Number footnotes by the order their references appear and omit a definition nothing references, matching what the renderer displays instead of emitting the source label. Keep a generated citation marker from resolving against a reference definition that happens to share its number, which linked the citation to the wrong URL. An unresolved reference now falls back to its own source form, as remark-rehype reverts one. Let a caller state the renderer its row was displayed with. SearchContent falls back to MarkdownLite for a message without content parts whatever its author, so search results and shared links pass that through. Replace the part-boundary sentinel with a citation processor that carries its numbering across parts, so each part is formatted on its own without a private-use character that message text could itself contain. * fix: close remaining gaps between rich text copies and the rendered message Route rooted /images/ sources through apiBaseUrl the way the renderer's image component does, so a subdirectory deployment's images survive the paste instead of losing the base path to the leading slash. Show an artifact directive by its title, which is what its button displays, rather than pasting the implementation the conversation hides. Gather footnote definitions into a footer ordered by first reference, matching where and in what order the renderer shows them. Keep a loose list's paragraph wrappers. mdast records looseness on the list, not on every item, so the parent decides whether an item is tight. Emit the citation footer as its own segment. Appended to a segment ending in an unclosed fence, the sources were swallowed into the code block. Pass the search results MessageParts already loads into useMessageHelpers, which was copying assistant responses with their citation markers stripped and no sources appended. * fix: transform MCP markers and default an untitled artifact Run mcpUIResourcePlugin over assistant segments as the full renderer does, so a UI resource marker leaves as nothing rather than pasting the protocol text. An embedded interactive resource has no static form, and MarkdownLite does not transform the markers, so user turns keep them literal exactly as they are displayed. Fall back to the Artifact component's own default title when a directive carries none, instead of dropping the artifact from the copy. * fix: honor definition precedence, file link rewrites and error rows Keep the first of two reference definitions sharing a label, as CommonMark does, instead of letting the last one win. Route a user's own generated-file links through the app's file endpoint the way the anchor component does, so a pasted link points where the one in the conversation points rather than at the provider URL. Skip the HTML flavor for an errored row. ErrorMessage renders those, so serializing the raw payload as markdown would paste something the conversation never showed. Scope reserved citation labels to the segment that generated them. Each part is its own document, so a legitimate reference in one part is no longer suppressed by a citation numbered the same in another. * fix: resolve file citations against the references collection File-search sources live under references, as useCitation reads them, but the clipboard's type map had no entry for file. Their markers were therefore stripped without a source, so a citation visible in the response went missing from the copy.
This commit is contained in:
parent
6733257391
commit
f174da6159
17 changed files with 1600 additions and 233 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ?? {};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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<TMessageProps, 'message'>) {
|
|||
className="final-completion"
|
||||
footer={
|
||||
<SubRow classes={message.isCreatedByUser ? 'justify-end text-xs' : 'text-xs'}>
|
||||
<MinimalHoverButtons message={message} />
|
||||
<MinimalHoverButtons
|
||||
message={message}
|
||||
variant={rendersMarkdownLite(message) ? 'lite' : undefined}
|
||||
/>
|
||||
<SearchButtons message={message} />
|
||||
</SubRow>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<MinimalHoverButtons message={message} searchResults={searchResults} />
|
||||
<MinimalHoverButtons
|
||||
message={message}
|
||||
searchResults={searchResults}
|
||||
variant={message.content && rendersMarkdownLite(message) ? 'lite' : undefined}
|
||||
/>
|
||||
</SubRow>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
206
client/src/hooks/Messages/useCopyMessageToClipboard.spec.tsx
Normal file
206
client/src/hooks/Messages/useCopyMessageToClipboard.spec.tsx
Normal file
|
|
@ -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<typeof copy>;
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(copiedHtml()).toBe('<p>:::warning<br />text<br />:::</p>');
|
||||
});
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(copiedHtml()).toBe('<p>text</p>');
|
||||
});
|
||||
|
||||
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('<p>E=mc^2</p>');
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockCopy.mockReturnValue(true);
|
||||
|
||||
const withoutLatex = renderWithSettings(
|
||||
{ copyRichText: true, enableUserMsgMarkdown: false, latexParsing: false },
|
||||
false,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
withoutLatex.result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(copiedHtml()).toBe('<p>$E=mc^2$</p>');
|
||||
});
|
||||
|
||||
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('<p>$E=mc^2$</p>');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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('<h1>Title</h1>\n<p>Some <strong>bold</strong> text.</p>');
|
||||
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('<h2>Findings</h2>');
|
||||
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('<p>Prose that renders on its own.</p>');
|
||||
expect(html).not.toContain('<code>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</a>');
|
||||
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('<p>Cited [1].</p>');
|
||||
expect(html).toContain('<a href="https://manual.example">manual</a>');
|
||||
});
|
||||
|
||||
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(
|
||||
'<p>Citations:<br />[1] <a href="https://example.com/1">https://example.com/1</a></p>',
|
||||
);
|
||||
expect(html).toContain('<code>const a = 1;</code></pre>');
|
||||
expect(html).not.toContain('Citations:</code>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
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<Pick<TMessage, 'text' | 'content'>> & {
|
||||
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<Pick<TMessage, 'text' | 'content'>>): string {
|
||||
function getMessageParts({ text, content }: Partial<Pick<TMessage, 'text' | 'content'>>): 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<Pick<TMessage, 'text' | 'content'>>,
|
||||
): 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<string>;
|
||||
};
|
||||
|
||||
/** 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<string> = new Set();
|
||||
|
||||
function buildCopyOptions(
|
||||
segments: ClipboardContent,
|
||||
richText: RichTextMode | undefined,
|
||||
): Parameters<typeof copy>[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<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -98,13 +187,14 @@ export default function useCopyToClipboard({ text, content, searchResults }: Cli
|
|||
|
||||
const copyToClipboard = useCallback(
|
||||
(setIsCopied: React.Dispatch<React.SetStateAction<boolean>>): 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<Pick<TMessage, 'isCreatedByUser' | 'error'>> & {
|
||||
/** 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<string, string>();
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
const replacements: Array<[string, string]> = [];
|
||||
const compositeCitationsMap = new Map<string, number[]>();
|
||||
|
||||
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<string>();
|
||||
const replacements: Array<[string, string]> = [];
|
||||
const compositeCitationsMap = new Map<string, number[]>();
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
254
client/src/utils/richtext.spec.ts
Normal file
254
client/src/utils/richtext.spec.ts
Normal file
|
|
@ -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(
|
||||
'<h1>Title</h1>\n<p>Some <strong>bold</strong> and <em>italic</em> text.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts unordered and ordered lists', () => {
|
||||
expect(markdownToHtml('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>');
|
||||
expect(markdownToHtml('3. three\n4. four')).toBe(
|
||||
'<ol start="3"><li>three</li><li>four</li></ol>',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks task list items', () => {
|
||||
expect(markdownToHtml('- [x] done\n- [ ] todo')).toBe(
|
||||
'<ul><li>☑ done</li><li>☐ todo</li></ul>',
|
||||
);
|
||||
});
|
||||
|
||||
it('styles tables so they keep their borders when pasted', () => {
|
||||
const html = markdownToHtml('| a | b |\n| --- | ---: |\n| 1 | 2 |');
|
||||
|
||||
expect(html).toContain('<table style="border-collapse:collapse;">');
|
||||
expect(html).toContain('<thead><tr><th style="border:1px solid #d0d7de;padding:6px 13px;');
|
||||
expect(html).toContain('text-align:right;">b</th>');
|
||||
expect(html).toContain('<tbody><tr><td');
|
||||
expect(html).toContain('>1</td>');
|
||||
});
|
||||
|
||||
it('keeps code blocks and inline code monospaced', () => {
|
||||
const html = markdownToHtml('Run `npm test`\n\n```js\nconst a = 1;\n```');
|
||||
|
||||
expect(html).toContain('<code style="font-family:ui-monospace');
|
||||
expect(html).toContain('npm test</code>');
|
||||
expect(html).toContain('<pre style="font-family:ui-monospace');
|
||||
expect(html).toContain('<code>const a = 1;</code></pre>');
|
||||
});
|
||||
|
||||
it('keeps safe links and drops unsafe ones', () => {
|
||||
expect(markdownToHtml('[docs](https://example.com)')).toBe(
|
||||
'<p><a href="https://example.com">docs</a></p>',
|
||||
);
|
||||
expect(markdownToHtml('[click](javascript:alert(1))')).toBe('<p>click</p>');
|
||||
});
|
||||
|
||||
it('escapes text, code and raw html instead of emitting markup', () => {
|
||||
expect(markdownToHtml('5 < 6 & "quoted"')).toBe('<p>5 < 6 & "quoted"</p>');
|
||||
expect(markdownToHtml('<script>alert(1)</script>')).toBe(
|
||||
'<script>alert(1)</script>',
|
||||
);
|
||||
expect(markdownToHtml('`<b>x</b>`')).toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('converts blockquotes, rules, strikethrough and images', () => {
|
||||
expect(markdownToHtml('> quoted')).toContain('<blockquote style="margin:0 0 16px;');
|
||||
expect(markdownToHtml('---')).toBe('<hr />');
|
||||
expect(markdownToHtml('~~gone~~')).toBe('<p><s>gone</s></p>');
|
||||
expect(markdownToHtml('')).toBe(
|
||||
'<p><img src="https://example.com/a.png" alt="alt" /></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders math as its LaTeX source and unwraps directives', () => {
|
||||
expect(markdownToHtml('$$E = mc^2$$')).toBe('<p>E = mc^2</p>');
|
||||
expect(markdownToHtml(':::note\ncontents\n:::')).toBe('<p>contents</p>');
|
||||
});
|
||||
|
||||
it('keeps directive markers literal for the lite renderer, which has no directives', () => {
|
||||
expect(markdownToHtml(':::warning\ntext\n:::', { variant: 'lite', latex: false })).toBe(
|
||||
'<p>:::warning<br />text<br />:::</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the supersub transform the renderers apply', () => {
|
||||
expect(markdownToHtml('x^2^ is squared')).toBe('<p>x<sup>2</sup> is squared</p>');
|
||||
expect(markdownToHtml('x^2^ is squared', { variant: 'lite', latex: false })).toBe(
|
||||
'<p>x<sup>2</sup> is squared</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an approximate tilde out of the subscript pairing', () => {
|
||||
expect(markdownToHtml('about ~50% of the time')).toBe('<p>about ∼50% of the time</p>');
|
||||
});
|
||||
|
||||
it('absolutizes relative links and images so they survive the paste', () => {
|
||||
expect(markdownToHtml('[file](/api/files/code/download/abc)')).toBe(
|
||||
`<p><a href="${new URL('/api/files/code/download/abc', document.baseURI).href}">file</a></p>`,
|
||||
);
|
||||
expect(markdownToHtml('')).toBe(
|
||||
`<p><img src="${new URL('/images/chart.png', document.baseURI).href}" alt="chart" /></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps soft line breaks visible, as the pre-wrap renderer does', () => {
|
||||
expect(markdownToHtml('first line\nsecond line')).toBe('<p>first line<br />second line</p>');
|
||||
});
|
||||
|
||||
it('keeps bare relative links and images, resolved against the app', () => {
|
||||
expect(markdownToHtml('[guide](docs/guide.html)')).toBe(
|
||||
`<p><a href="${new URL('docs/guide.html', document.baseURI).href}">guide</a></p>`,
|
||||
);
|
||||
expect(markdownToHtml('')).toBe(
|
||||
`<p><img src="${new URL('images/plot.png', document.baseURI).href}" alt="plot" /></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders single-dollar math when the renderer preprocesses LaTeX', () => {
|
||||
expect(markdownToHtml('Energy is $E=mc^2$ inline.', { variant: 'full', latex: true })).toBe(
|
||||
'<p>Energy is E=mc^2 inline.</p>',
|
||||
);
|
||||
expect(markdownToHtml('Energy is $E=mc^2$ inline.')).toBe('<p>Energy is $E=mc^2$ inline.</p>');
|
||||
});
|
||||
|
||||
it('still leaves currency alone with LaTeX preprocessing on', () => {
|
||||
expect(markdownToHtml('Costs rose from $5 to $10.', { variant: 'full', latex: true })).toBe(
|
||||
'<p>Costs rose from $5 to $10.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an inline directive by name, as the artifact plugin does', () => {
|
||||
expect(markdownToHtml('a :foo[bar] b')).toBe('<p>a :foo b</p>');
|
||||
});
|
||||
|
||||
it('omits a footnote definition nothing references, as the renderer does', () => {
|
||||
expect(markdownToHtml('Plain text.\n\n[^unused]: never cited')).toBe('<p>Plain text.</p>');
|
||||
});
|
||||
|
||||
it('keeps reserved labels from capturing a reference', () => {
|
||||
const markdown = 'Cited [1].\n\n[1]: https://other.example';
|
||||
|
||||
expect(markdownToHtml(markdown)).toBe('<p>Cited <a href="https://other.example">1</a>.</p>');
|
||||
expect(
|
||||
markdownToHtml(markdown, { variant: 'full', latex: false, reserved: new Set(['1']) }),
|
||||
).toBe('<p>Cited [1].</p>');
|
||||
});
|
||||
|
||||
it('keeps a loose list wrapped in paragraphs, as the renderer does', () => {
|
||||
expect(markdownToHtml('- one\n\n- two')).toBe(
|
||||
'<ul><li><p>one</p></li><li><p>two</p></li></ul>',
|
||||
);
|
||||
expect(markdownToHtml('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>');
|
||||
});
|
||||
|
||||
it('shows an artifact by its title, which is what the button displays', () => {
|
||||
expect(markdownToHtml(':::artifact{title="Chart component"}\ncode here\n:::')).toBe(
|
||||
'<p>Chart component</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the button default for an untitled artifact', () => {
|
||||
expect(markdownToHtml('::artifact{identifier="a" type="x"}')).toBe('<p>untitled</p>');
|
||||
});
|
||||
|
||||
it('drops MCP resource markers rather than pasting the protocol text', () => {
|
||||
expect(markdownToHtml('See \\ui{resource1} here.')).toBe('<p>See here.</p>');
|
||||
expect(markdownToHtml('See \\ui{a,b} here.')).toBe('<p>See here.</p>');
|
||||
});
|
||||
|
||||
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(
|
||||
'<p>See \\ui{resource1} here.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes rooted image sources through the deployment base', () => {
|
||||
const html = markdownToHtml('');
|
||||
expect(html).toBe(
|
||||
`<p><img src="${new URL(`${apiBaseUrl()}/images/chart.png`, document.baseURI).href}" alt="chart" /></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<p>Second<sup>1</sup> then first<sup>2</sup>.</p>\n' +
|
||||
'<div><sup>1</sup><p>beta</p></div>\n' +
|
||||
'<div><sup>2</sup><p>alpha</p></div>',
|
||||
);
|
||||
});
|
||||
|
||||
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('<p><a href="https://first.example">docs</a></p>');
|
||||
});
|
||||
|
||||
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(
|
||||
`<p><a href="${new URL(`${apiBaseUrl()}/api/files/user-1/file-1/report.csv`, document.baseURI).href}">report</a></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
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(`<p><a href="${url}">report</a></p>`);
|
||||
});
|
||||
|
||||
it('leaves paired currency alone, as the renderer does', () => {
|
||||
expect(markdownToHtml('Costs rose from $5 to $10.')).toBe('<p>Costs rose from $5 to $10.</p>');
|
||||
});
|
||||
|
||||
it('resolves reference-style links and images against their definitions', () => {
|
||||
expect(markdownToHtml('See [docs][guide].\n\n[guide]: https://example.com')).toBe(
|
||||
'<p>See <a href="https://example.com">docs</a>.</p>',
|
||||
);
|
||||
expect(markdownToHtml('![logo][pic]\n\n[pic]: https://example.com/a.png')).toBe(
|
||||
'<p><img src="https://example.com/a.png" alt="logo" /></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to plain text when a reference has no definition', () => {
|
||||
expect(markdownToHtml('See [docs][missing].')).toBe('<p>See [docs][missing].</p>');
|
||||
});
|
||||
|
||||
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(
|
||||
'<blockquote style="margin:0 0 16px;padding:0 1em;border-left:4px solid #d0d7de;">',
|
||||
);
|
||||
expect(markdownToHtml('| a |\n| --- |\n| b |')).toContain(
|
||||
'<td style="border:1px solid #d0d7de;padding:6px 13px;">b</td>',
|
||||
);
|
||||
});
|
||||
});
|
||||
465
client/src/utils/richtext.ts
Normal file
465
client/src/utils/richtext.ts
Normal file
|
|
@ -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<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `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<string> = 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<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
};
|
||||
|
||||
type SerializeContext = {
|
||||
userId?: string;
|
||||
definitions: Map<string, Definition>;
|
||||
/** Footnote label to its displayed number, in the order references appear. */
|
||||
footnotes: Map<string, number>;
|
||||
reserved: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
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, '<br />');
|
||||
|
||||
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<string, string | null | undefined> | null | undefined) =>
|
||||
`<p>${escapeText(attributes?.title || ARTIFACT_DEFAULT_TITLE)}</p>`;
|
||||
|
||||
/**
|
||||
* 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 ? `<a href="${escapeHtml(resolved)}">${children}</a>` : 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
|
||||
? `<img src="${escapeHtml(resolved)}" alt="${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,
|
||||
)}</${tag}>`;
|
||||
}
|
||||
return `<tr>${html}</tr>`;
|
||||
};
|
||||
|
||||
const head = headerRow ? `<thead>${cells(headerRow, 'th', STYLES.headerCell)}</thead>` : '';
|
||||
|
||||
let body = '';
|
||||
for (const row of bodyRows) {
|
||||
body += cells(row, 'td', STYLES.cell);
|
||||
}
|
||||
|
||||
return `<table style="${STYLES.table}">${head}${body && `<tbody>${body}</tbody>`}</table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<p>${serializeChildren(node.children, context)}</p>`;
|
||||
case 'heading':
|
||||
return `<h${node.depth}>${serializeChildren(node.children, context)}</h${node.depth}>`;
|
||||
case 'strong':
|
||||
return `<strong>${serializeChildren(node.children, context)}</strong>`;
|
||||
case 'emphasis':
|
||||
return `<em>${serializeChildren(node.children, context)}</em>`;
|
||||
case 'delete':
|
||||
return `<s>${serializeChildren(node.children, context)}</s>`;
|
||||
case 'superscript':
|
||||
return `<sup>${serializeChildren(node.children, context)}</sup>`;
|
||||
case 'subscript':
|
||||
return `<sub>${serializeChildren(node.children, context)}</sub>`;
|
||||
case 'inlineCode':
|
||||
return `<code style="${STYLES.inlineCode}">${escapeHtml(node.value)}</code>`;
|
||||
case 'code':
|
||||
return `<pre style="${STYLES.codeBlock}"><code>${escapeHtml(node.value)}</code></pre>`;
|
||||
case 'blockquote':
|
||||
return `<blockquote style="${STYLES.blockquote}">${serializeChildren(
|
||||
node.children,
|
||||
context,
|
||||
)}</blockquote>`;
|
||||
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 '<hr />';
|
||||
case 'break':
|
||||
return '<br />';
|
||||
case 'inlineMath':
|
||||
return escapeText(node.value);
|
||||
case 'math':
|
||||
return `<p>${escapeText(node.value)}</p>`;
|
||||
case 'footnoteReference':
|
||||
return `<sup>${context.footnotes.get(node.identifier) ?? ''}</sup>`;
|
||||
case 'footnoteDefinition': {
|
||||
const number = context.footnotes.get(node.identifier);
|
||||
if (number == null) {
|
||||
return '';
|
||||
}
|
||||
return `<div><sup>${number}</sup>${serializeChildren(node.children, context)}</div>`;
|
||||
}
|
||||
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 `<li>${taskMarker(node.checked)}${children}</li>`;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue