diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index f1ac903ece..1e214278c9 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -1,4 +1,5 @@ const express = require('express'); +const { unescapeLaTeX } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { ContentTypes } = require('librechat-data-provider'); const { @@ -134,17 +135,32 @@ router.post('/artifact/:messageId', async (req, res) => { return res.status(400).json({ error: 'Artifact index out of bounds' }); } + // Unescape LaTeX preprocessing done by the frontend + // The frontend escapes $ signs for display, but the database has unescaped versions + const unescapedOriginal = unescapeLaTeX(original); + const unescapedUpdated = unescapeLaTeX(updated); + const targetArtifact = artifacts[index]; let updatedText = null; if (targetArtifact.source === 'content') { const part = message.content[targetArtifact.partIndex]; - updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated); + updatedText = replaceArtifactContent( + part.text, + targetArtifact, + unescapedOriginal, + unescapedUpdated, + ); if (updatedText) { part.text = updatedText; } } else { - updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated); + updatedText = replaceArtifactContent( + message.text, + targetArtifact, + unescapedOriginal, + unescapedUpdated, + ); if (updatedText) { message.text = updatedText; } diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 85c99d108f..888190af52 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -7,6 +7,7 @@ export * from './events'; export * from './files'; export * from './generators'; export * from './key'; +export * from './latex'; export * from './llm'; export * from './math'; export * from './openid'; diff --git a/packages/api/src/utils/latex.spec.ts b/packages/api/src/utils/latex.spec.ts new file mode 100644 index 0000000000..75383609a4 --- /dev/null +++ b/packages/api/src/utils/latex.spec.ts @@ -0,0 +1,122 @@ +import { unescapeLaTeX } from './latex'; + +describe('unescapeLaTeX', () => { + describe('currency dollar signs', () => { + it('should unescape single backslash dollar signs', () => { + const input = 'Price: \\$14'; + const expected = 'Price: $14'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + + it('should unescape double backslash dollar signs', () => { + const input = 'Price: \\\\$14'; + const expected = 'Price: $14'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + + it('should unescape multiple currency values', () => { + const input = '**Crispy Calamari** - *\\\\$14*\n**Truffle Fries** - *\\\\$12*'; + const expected = '**Crispy Calamari** - *$14*\n**Truffle Fries** - *$12*'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + + it('should handle currency with commas and decimals', () => { + const input = 'Total: \\\\$1,234.56'; + const expected = 'Total: $1,234.56'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + }); + + describe('mhchem notation', () => { + it('should unescape mhchem ce notation', () => { + const input = '$$\\\\ce{H2O}$$'; + const expected = '$\\ce{H2O}$'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + + it('should unescape mhchem pu notation', () => { + const input = '$$\\\\pu{123 kJ/mol}$$'; + const expected = '$\\pu{123 kJ/mol}$'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + + it('should handle multiple mhchem expressions', () => { + const input = '$$\\\\ce{H2O}$$ and $$\\\\ce{CO2}$$'; + const expected = '$\\ce{H2O}$ and $\\ce{CO2}$'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(unescapeLaTeX('')).toBe(''); + }); + + it('should handle null', () => { + expect(unescapeLaTeX(null)).toBe(null); + }); + + it('should handle undefined', () => { + expect(unescapeLaTeX(undefined)).toBe(undefined); + }); + + it('should handle string with no dollar signs', () => { + const input = 'Hello world'; + expect(unescapeLaTeX(input)).toBe(input); + }); + + it('should handle mixed escaped and unescaped content', () => { + const input = 'Price \\\\$14 and some text'; + const expected = 'Price $14 and some text'; + expect(unescapeLaTeX(input)).toBe(expected); + }); + }); + + describe('real-world example from bug report', () => { + it('should correctly unescape restaurant menu content', () => { + const input = `# The Golden Spoon +## *Contemporary American Cuisine* + +--- + +### STARTERS + +**Crispy Calamari** - *\\\\$14* +Lightly fried, served with marinara & lemon aioli + +**Truffle Fries** - *\\\\$12* +Hand-cut fries, parmesan, truffle oil, fresh herbs + +**Burrata & Heirloom Tomatoes** - *\\\\$16* +Fresh burrata, basil pesto, balsamic reduction, grilled sourdough + +**Thai Chicken Lettuce Wraps** - *\\\\$13* +Spicy ground chicken, water chestnuts, ginger-soy glaze + +**Soup of the Day** - *\\\\$9`; + + const expected = `# The Golden Spoon +## *Contemporary American Cuisine* + +--- + +### STARTERS + +**Crispy Calamari** - *$14* +Lightly fried, served with marinara & lemon aioli + +**Truffle Fries** - *$12* +Hand-cut fries, parmesan, truffle oil, fresh herbs + +**Burrata & Heirloom Tomatoes** - *$16* +Fresh burrata, basil pesto, balsamic reduction, grilled sourdough + +**Thai Chicken Lettuce Wraps** - *$13* +Spicy ground chicken, water chestnuts, ginger-soy glaze + +**Soup of the Day** - *$9`; + + expect(unescapeLaTeX(input)).toBe(expected); + }); + }); +}); diff --git a/packages/api/src/utils/latex.ts b/packages/api/src/utils/latex.ts new file mode 100644 index 0000000000..4214dc050b --- /dev/null +++ b/packages/api/src/utils/latex.ts @@ -0,0 +1,27 @@ +/** + * Unescapes LaTeX preprocessing done by the frontend preprocessLaTeX function. + * This reverses the escaping of currency dollar signs and other LaTeX transformations. + * + * The frontend escapes dollar signs for proper LaTeX rendering (e.g., $14 → \\$14), + * but the database stores the original unescaped versions. This function reverses + * that transformation to match database content. + * + * @param text - The escaped text from the frontend + * @returns The unescaped text matching the database format + */ +export function unescapeLaTeX(text: string | null | undefined): string | null | undefined { + if (!text || typeof text !== 'string') { + return text; + } + + // Unescape currency dollar signs (\\$ or \$ → $) + // This is the main transformation done by preprocessLaTeX for currency + let result = text.replace(/\\\\?\$/g, '$'); + + // Unescape mhchem notation if present + // Convert $$\\ce{...}$$ back to $\ce{...}$ + result = result.replace(/\$\$\\\\ce\{([^}]*)\}\$\$/g, '$\\ce{$1}$'); + result = result.replace(/\$\$\\\\pu\{([^}]*)\}\$\$/g, '$\\pu{$1}$'); + + return result; +}