mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
Merge 08650405f6 into 6b5596ec36
This commit is contained in:
commit
9719db6cba
2 changed files with 223 additions and 27 deletions
|
|
@ -37,9 +37,7 @@ describe('useCopyToClipboard', () => {
|
|||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Simple text without citations', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith('Simple text without citations', expect.objectContaining({ format: 'text/plain' }));
|
||||
expect(mockSetIsCopied).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
|
|
@ -59,9 +57,7 @@ describe('useCopyToClipboard', () => {
|
|||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('First line\nSecond line', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith('First line\nSecond line', expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should reset isCopied after timeout', () => {
|
||||
|
|
@ -83,6 +79,31 @@ describe('useCopyToClipboard', () => {
|
|||
|
||||
expect(mockSetIsCopied).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should convert markdown tables to tab-separated text for spreadsheet paste', () => {
|
||||
const text = `| Name | Age | Occupation |
|
||||
| ------- | --- | ---------- |
|
||||
| Michael | 35 | Engineer |
|
||||
| Sarah | 28 | Doctor |
|
||||
| Tracy | 45 | Teacher |`;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Name\tAge\tOccupation
|
||||
Michael\t35\tEngineer
|
||||
Sarah\t28\tDoctor
|
||||
Tracy\t45\tTeacher`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citation formatting', () => {
|
||||
|
|
@ -140,7 +161,7 @@ Citations:
|
|||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should format news citations with correct mapping', () => {
|
||||
|
|
@ -164,7 +185,7 @@ Citations:
|
|||
[2] https://example.com/news2
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should handle highlighted text with citations', () => {
|
||||
|
|
@ -181,13 +202,43 @@ Citations:
|
|||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `**This is highlighted text** [1] with citation.
|
||||
const expectedText = `This is highlighted text [1] with citation.
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should preserve citation output while converting markdown tables', () => {
|
||||
const text = `| Name | Score |
|
||||
| --- | --- |
|
||||
| John | 91 |
|
||||
|
||||
Source \\ue202turn0search0`;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCopyToClipboard({
|
||||
text,
|
||||
searchResults: mockSearchResults,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `Name\tScore
|
||||
John\t91
|
||||
|
||||
Source [1]
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should handle composite citations', () => {
|
||||
|
|
@ -213,7 +264,7 @@ Citations:
|
|||
[3] https://example.com/news2
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -255,7 +306,7 @@ Citations:
|
|||
[1] https://example.com/article
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should handle multiple citations of the same source', () => {
|
||||
|
|
@ -290,7 +341,7 @@ Citations:
|
|||
[1] https://example.com/source1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -310,9 +361,7 @@ Citations:
|
|||
});
|
||||
|
||||
// Updated expectation: Citation marker should be removed
|
||||
expect(mockCopy).toHaveBeenCalledWith('Text with citation but no data.', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith('Text with citation but no data.', expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should handle invalid citation indices', () => {
|
||||
|
|
@ -347,7 +396,7 @@ Citations:
|
|||
[1] https://example.com/search1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should handle citations without links', () => {
|
||||
|
|
@ -376,9 +425,7 @@ Citations:
|
|||
});
|
||||
|
||||
// Updated expectation: Citation marker without link should be removed
|
||||
expect(mockCopy).toHaveBeenCalledWith('Citation without link.', {
|
||||
format: 'text/plain',
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith('Citation without link.', expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
|
||||
it('should clean up orphaned citation lists at the end', () => {
|
||||
|
|
@ -410,7 +457,7 @@ Citations:
|
|||
[1] https://example.com/1
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -450,7 +497,7 @@ Citations:
|
|||
[5] https://example.com/ref
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -480,7 +527,7 @@ Citations:
|
|||
result.current(mockSetIsCopied);
|
||||
});
|
||||
|
||||
const expectedText = `**Highlighted text with citation** [1] and composite [2][3].
|
||||
const expectedText = `Highlighted text with citation [1] and composite [2][3].
|
||||
|
||||
Citations:
|
||||
[1] https://example.com/1
|
||||
|
|
@ -488,7 +535,7 @@ Citations:
|
|||
[3] https://example.com/3
|
||||
`;
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
|
||||
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ export default function useCopyToClipboard({
|
|||
if (content) {
|
||||
messageText = content.reduce((acc, curr, i) => {
|
||||
if (curr.type === ContentTypes.TEXT) {
|
||||
const text = typeof curr.text === 'string' ? curr.text : curr.text.value;
|
||||
const textPart = curr.text;
|
||||
const text =
|
||||
typeof textPart === 'string' ? textPart : (textPart?.value ?? textPart?.toString()) || '';
|
||||
return acc + text + (i === content.length - 1 ? '' : '\n');
|
||||
}
|
||||
return acc;
|
||||
|
|
@ -69,8 +71,16 @@ export default function useCopyToClipboard({
|
|||
const cleanedText = messageText
|
||||
.replace(INVALID_CITATION_REGEX, '')
|
||||
.replace(CLEANUP_REGEX, '');
|
||||
const markdownText = cleanedText;
|
||||
const plainText = normalizeClipboardPlainText(transformMarkdownTablesToTSV(markdownText));
|
||||
const htmlText = buildClipboardHTML(markdownText);
|
||||
|
||||
copy(cleanedText, { format: 'text/plain' });
|
||||
copy(plainText, {
|
||||
format: 'text/plain',
|
||||
onCopy: (clipboardData) => {
|
||||
setHTMLClipboardData(clipboardData, htmlText);
|
||||
},
|
||||
});
|
||||
copyTimeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
|
|
@ -95,7 +105,14 @@ export default function useCopyToClipboard({
|
|||
}
|
||||
}
|
||||
|
||||
copy(processedText, { format: 'text/plain' });
|
||||
const plainText = normalizeClipboardPlainText(transformMarkdownTablesToTSV(processedText));
|
||||
const htmlText = buildClipboardHTML(processedText);
|
||||
copy(plainText, {
|
||||
format: 'text/plain',
|
||||
onCopy: (clipboardData) => {
|
||||
setHTMLClipboardData(clipboardData, htmlText);
|
||||
},
|
||||
});
|
||||
copyTimeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
|
|
@ -341,3 +358,135 @@ function processCitations(text: string, searchResults: { [key: string]: SearchRe
|
|||
citations,
|
||||
};
|
||||
}
|
||||
|
||||
function transformMarkdownTablesToTSV(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const transformedLines: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const nextLine = lines[index + 1];
|
||||
|
||||
if (isTableRow(line) && isSeparatorRow(nextLine)) {
|
||||
transformedLines.push(convertTableRowToTSV(line));
|
||||
index += 2;
|
||||
|
||||
while (index < lines.length && isTableRow(lines[index])) {
|
||||
transformedLines.push(convertTableRowToTSV(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
transformedLines.push(line);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return transformedLines.join('\n');
|
||||
}
|
||||
|
||||
function isTableRow(line?: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
return trimmed.includes('|') && trimmed.replace(/\|/g, '').trim().length > 0;
|
||||
}
|
||||
|
||||
function isSeparatorRow(line?: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = line
|
||||
.trim()
|
||||
.replace(/\|/g, '')
|
||||
.replace(/:/g, '')
|
||||
.replace(/-/g, '')
|
||||
.replace(/\s/g, '');
|
||||
if (normalized.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return line.includes('|') && line.includes('-');
|
||||
}
|
||||
|
||||
function convertTableRowToTSV(row: string): string {
|
||||
return parseTableCells(row).join('\t');
|
||||
}
|
||||
|
||||
function normalizeClipboardPlainText(text: string): string {
|
||||
return text.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
}
|
||||
|
||||
function buildClipboardHTML(markdownText: string): string {
|
||||
const lines = markdownText.split('\n');
|
||||
const htmlParts: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const nextLine = lines[index + 1];
|
||||
|
||||
if (isTableRow(line) && isSeparatorRow(nextLine)) {
|
||||
const headers = parseTableCells(line);
|
||||
index += 2;
|
||||
const rows: string[][] = [];
|
||||
|
||||
while (index < lines.length && isTableRow(lines[index])) {
|
||||
rows.push(parseTableCells(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const tableHeader = `<thead><tr>${headers.map((cell) => `<th>${inlineMarkdownToHTML(cell)}</th>`).join('')}</tr></thead>`;
|
||||
const tableBody = `<tbody>${rows
|
||||
.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHTML(cell)}</td>`).join('')}</tr>`)
|
||||
.join('')}</tbody>`;
|
||||
htmlParts.push(`<table>${tableHeader}${tableBody}</table>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().length === 0) {
|
||||
htmlParts.push('<br />');
|
||||
} else {
|
||||
htmlParts.push(`<p>${inlineMarkdownToHTML(line)}</p>`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return `<div>${htmlParts.join('')}</div>`;
|
||||
}
|
||||
|
||||
function parseTableCells(row: string): string[] {
|
||||
const trimmed = row.trim();
|
||||
const noBoundaryPipes = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
||||
return noBoundaryPipes
|
||||
.split(/(?<!\\)\|/)
|
||||
.map((cell) => cell.replace(/\\\|/g, '|').trim());
|
||||
}
|
||||
|
||||
function inlineMarkdownToHTML(text: string): string {
|
||||
const escaped = escapeHTML(text);
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
}
|
||||
|
||||
function escapeHTML(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function setHTMLClipboardData(clipboardData: unknown, htmlText: string): void {
|
||||
if (!clipboardData || typeof clipboardData !== 'object' || !('setData' in clipboardData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboard = clipboardData as { setData: (mime: string, data: string) => void };
|
||||
clipboard.setData('text/html', htmlText);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue