From 3aed0e9e05b45e4f12401350fde258a040aec5d9 Mon Sep 17 00:00:00 2001 From: Danny Avila <110412045+danny-avila@users.noreply.github.com> Date: Thu, 18 Jan 2024 14:44:10 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20feat:=20LaTeX=20parsing=20?= =?UTF-8?q?for=20Messages=20(#1585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Beta features tab in Settings and LaTeX Parsing toggle * feat: LaTex parsing with spec --- .../Chat/Messages/Content/Markdown.tsx | 12 ++- client/src/components/Nav/Settings.tsx | 26 ++++-- .../Nav/SettingsTabs/Account/Account.tsx | 9 +- .../components/Nav/SettingsTabs/Beta/Beta.tsx | 26 ++++++ .../Nav/SettingsTabs/Beta/LaTeXParsing.tsx | 33 +++++++ .../{General => Beta}/ModularChat.tsx | 4 +- .../components/Nav/SettingsTabs/Data/Data.tsx | 7 +- .../Nav/SettingsTabs/General/General.tsx | 7 +- .../src/components/Nav/SettingsTabs/index.ts | 1 + client/src/components/svg/ExperimentIcon.tsx | 27 ++++++ client/src/components/svg/index.ts | 1 + client/src/localization/languages/Eng.tsx | 5 +- client/src/store/settings.ts | 20 +++++ client/src/utils/index.ts | 1 + client/src/utils/latex.spec.ts | 86 +++++++++++++++++++ client/src/utils/latex.ts | 24 ++++++ packages/data-provider/src/config.ts | 22 +++++ 17 files changed, 290 insertions(+), 21 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/Beta/Beta.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Beta/LaTeXParsing.tsx rename client/src/components/Nav/SettingsTabs/{General => Beta}/ModularChat.tsx (87%) create mode 100644 client/src/components/svg/ExperimentIcon.tsx create mode 100644 client/src/utils/latex.spec.ts create mode 100644 client/src/utils/latex.ts diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 0b9587a2c5f..b32d62b4d87 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -1,3 +1,4 @@ +import { useRecoilValue } from 'recoil'; import React, { useState, useEffect } from 'react'; import type { TMessage } from 'librechat-data-provider'; import rehypeHighlight from 'rehype-highlight'; @@ -8,9 +9,10 @@ import rehypeKatex from 'rehype-katex'; import remarkMath from 'remark-math'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; -import { useChatContext } from '~/Providers'; -import { langSubset, validateIframe } from '~/utils'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; +import { langSubset, validateIframe, processLaTeX } from '~/utils'; +import { useChatContext } from '~/Providers'; +import store from '~/store'; type TCodeProps = { inline: boolean; @@ -42,11 +44,15 @@ const p = React.memo(({ children }: { children: React.ReactNode }) => { const Markdown = React.memo(({ content, message, showCursor }: TContentProps) => { const [cursor, setCursor] = useState('█'); const { isSubmitting, latestMessage } = useChatContext(); + const LaTeXParsing = useRecoilValue(store.LaTeXParsing); + const isInitializing = content === ''; const { isEdited, messageId } = message ?? {}; const isLatestMessage = messageId === latestMessage?.messageId; - const currentContent = content?.replace('z-index: 1;', '') ?? ''; + + const _content = content?.replace('z-index: 1;', '') ?? ''; + const currentContent = LaTeXParsing ? processLaTeX(_content) : _content; useEffect(() => { let timer1: NodeJS.Timeout, timer2: NodeJS.Timeout; diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 01196e1cf7d..39c6a59beea 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -1,8 +1,9 @@ import * as Tabs from '@radix-ui/react-tabs'; +import { SettingsTabValues } from 'librechat-data-provider'; import type { TDialogProps } from '~/common'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui'; -import { GearIcon, DataIcon, UserIcon } from '~/components/svg'; -import { General, Data, Account } from './SettingsTabs'; +import { GearIcon, DataIcon, UserIcon, ExperimentIcon } from '~/components/svg'; +import { General, Beta, Data, Account } from './SettingsTabs'; import { useMediaQuery, useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -23,7 +24,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
@@ -44,7 +45,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { ? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white' : '', )} - value="general" + value={SettingsTabValues.GENERAL} > {localize('com_nav_setting_general')} @@ -56,7 +57,19 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { ? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white' : '', )} - value="data" + value={SettingsTabValues.BETA} + > + + {localize('com_nav_setting_beta')} + + {localize('com_nav_setting_data')} @@ -68,13 +81,14 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { ? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white' : '', )} - value="account" + value={SettingsTabValues.ACCOUNT} > {localize('com_nav_setting_account')} + diff --git a/client/src/components/Nav/SettingsTabs/Account/Account.tsx b/client/src/components/Nav/SettingsTabs/Account/Account.tsx index a7651ddca60..daff05f9f86 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Account.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Account.tsx @@ -1,10 +1,15 @@ +import React from 'react'; import * as Tabs from '@radix-ui/react-tabs'; +import { SettingsTabValues } from 'librechat-data-provider'; import Avatar from './Avatar'; -import React from 'react'; function Account() { return ( - +
diff --git a/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx b/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx new file mode 100644 index 00000000000..7ea4e32a432 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx @@ -0,0 +1,26 @@ +import { memo } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { SettingsTabValues } from 'librechat-data-provider'; +import LaTeXParsing from './LaTeXParsing'; +import ModularChat from './ModularChat'; + +function Beta() { + return ( + +
+
+ +
+
+ +
+
+
+ ); +} + +export default memo(Beta); diff --git a/client/src/components/Nav/SettingsTabs/Beta/LaTeXParsing.tsx b/client/src/components/Nav/SettingsTabs/Beta/LaTeXParsing.tsx new file mode 100644 index 00000000000..a7470d0b175 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Beta/LaTeXParsing.tsx @@ -0,0 +1,33 @@ +import { useRecoilState } from 'recoil'; +import { Switch } from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +export default function LaTeXParsingSwitch({ + onCheckedChange, +}: { + onCheckedChange?: (value: boolean) => void; +}) { + const [LaTeXParsing, setLaTeXParsing] = useRecoilState(store.LaTeXParsing); + const localize = useLocalize(); + + const handleCheckedChange = (value: boolean) => { + setLaTeXParsing(value); + if (onCheckedChange) { + onCheckedChange(value); + } + }; + + return ( +
+
{localize('com_nav_latex_parsing')}
+ +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/ModularChat.tsx b/client/src/components/Nav/SettingsTabs/Beta/ModularChat.tsx similarity index 87% rename from client/src/components/Nav/SettingsTabs/General/ModularChat.tsx rename to client/src/components/Nav/SettingsTabs/Beta/ModularChat.tsx index 757c96bdde0..a1324e7fcf8 100644 --- a/client/src/components/Nav/SettingsTabs/General/ModularChat.tsx +++ b/client/src/components/Nav/SettingsTabs/Beta/ModularChat.tsx @@ -20,9 +20,7 @@ export default function ModularChatSwitch({ return (
-
- {`[${localize('com_ui_experimental')}]`} {localize('com_nav_modular_chat')}{' '} -
+
{localize('com_nav_modular_chat')}
+
diff --git a/client/src/components/Nav/SettingsTabs/General/General.tsx b/client/src/components/Nav/SettingsTabs/General/General.tsx index 8cc59537656..28a0f2eb275 100644 --- a/client/src/components/Nav/SettingsTabs/General/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General/General.tsx @@ -1,5 +1,6 @@ import { useRecoilState } from 'recoil'; import * as Tabs from '@radix-ui/react-tabs'; +import { SettingsTabValues } from 'librechat-data-provider'; import React, { useState, useContext, useCallback, useRef } from 'react'; import { useClearConversationsMutation } from 'librechat-data-provider/react-query'; import { @@ -14,7 +15,6 @@ import type { TDangerButtonProps } from '~/common'; import AutoScrollSwitch from './AutoScrollSwitch'; import { Dropdown } from '~/components/ui'; import DangerButton from '../DangerButton'; -import ModularChat from './ModularChat'; import store from '~/store'; export const ThemeSelector = ({ @@ -167,7 +167,7 @@ function General() { return (
-
- -
); diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 73174aa7984..71e8c36c198 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -1,5 +1,6 @@ export { default as General } from './General/General'; export { ClearChatsButton } from './General/General'; export { default as Data } from './Data/Data'; +export { default as Beta } from './Beta/Beta'; export { RevokeKeysButton } from './Data/Data'; export { default as Account } from './Account/Account'; diff --git a/client/src/components/svg/ExperimentIcon.tsx b/client/src/components/svg/ExperimentIcon.tsx new file mode 100644 index 00000000000..f3de09bd09f --- /dev/null +++ b/client/src/components/svg/ExperimentIcon.tsx @@ -0,0 +1,27 @@ +export default function ExperimentIcon() { + return ( + + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 5421233eaab..ac4ad7f0b8f 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -42,3 +42,4 @@ export { default as GoogleMinimalIcon } from './GoogleMinimalIcon'; export { default as AnthropicMinimalIcon } from './AnthropicMinimalIcon'; export { default as SendMessageIcon } from './SendMessageIcon'; export { default as UserIcon } from './UserIcon'; +export { default as ExperimentIcon } from './ExperimentIcon'; diff --git a/client/src/localization/languages/Eng.tsx b/client/src/localization/languages/Eng.tsx index e3c023c66bc..296fe6833d2 100644 --- a/client/src/localization/languages/Eng.tsx +++ b/client/src/localization/languages/Eng.tsx @@ -15,7 +15,7 @@ export default { com_ui_limitation_harmful_biased: 'May occasionally produce harmful instructions or biased content', com_ui_limitation_limited_2021: 'Limited knowledge of world and events after 2021', - com_ui_experimental: 'Experimental', + com_ui_experimental: 'Experimental Features', com_ui_input: 'Input', com_ui_close: 'Close', com_ui_model: 'Model', @@ -265,6 +265,8 @@ export default { com_nav_welcome_message: 'How can I help you today?', com_nav_auto_scroll: 'Auto-scroll to Newest on Open', com_nav_modular_chat: 'Enable switching Endpoints mid-conversation', + com_nav_latex_parsing: + 'Toggle parsing LaTeX in messages. Enabled by default but may affect performance on mobile or longer conversations.', com_nav_profile_picture: 'Profile Picture', com_nav_change_picture: 'Change picture', com_nav_plugin_store: 'Plugin store', @@ -303,6 +305,7 @@ export default { com_nav_settings: 'Settings', com_nav_search_placeholder: 'Search messages', com_nav_setting_general: 'General', + com_nav_setting_beta: 'Beta features', com_nav_setting_data: 'Data controls', com_nav_setting_account: 'Account', com_nav_language: 'Language', diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index d580e4bf3b6..9c1b572b5e1 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -69,6 +69,25 @@ const modularChat = atom({ ] as const, }); +const LaTeXParsing = atom({ + key: 'LaTeXParsing', + default: true, + effects: [ + ({ setSelf, onSet }) => { + const savedValue = localStorage.getItem('LaTeXParsing'); + if (savedValue != null) { + setSelf(savedValue === 'true'); + } + + onSet((newValue: unknown) => { + if (typeof newValue === 'boolean') { + localStorage.setItem('LaTeXParsing', newValue.toString()); + } + }); + }, + ] as const, +}); + export default { abortScroll, optionSettings, @@ -78,4 +97,5 @@ export default { showPopover, autoScroll, modularChat, + LaTeXParsing, }; diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 12a32faf636..e329ed69dd5 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './json'; export * from './files'; +export * from './latex'; export * from './presets'; export * from './languages'; export * from './endpoints'; diff --git a/client/src/utils/latex.spec.ts b/client/src/utils/latex.spec.ts new file mode 100644 index 00000000000..9cb483dd27d --- /dev/null +++ b/client/src/utils/latex.spec.ts @@ -0,0 +1,86 @@ +import { processLaTeX } from './latex'; + +describe('processLaTeX', () => { + test('returns the same string if no LaTeX patterns are found', () => { + const content = 'This is a test string without LaTeX'; + expect(processLaTeX(content)).toBe(content); + }); + + test('converts inline LaTeX expressions correctly', () => { + const content = 'This is an inline LaTeX expression: \\(x^2 + y^2 = z^2\\)'; + const expected = 'This is an inline LaTeX expression: $x^2 + y^2 = z^2$'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('converts block LaTeX expressions correctly', () => { + const content = 'This is a block LaTeX expression: \\[E = mc^2\\]'; + const expected = 'This is a block LaTeX expression: $$E = mc^2$$'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('converts mixed LaTeX expressions correctly', () => { + const content = 'Inline \\(a + b = c\\) and block \\[x^2 + y^2 = z^2\\]'; + const expected = 'Inline $a + b = c$ and block $$x^2 + y^2 = z^2$$'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('escapes dollar signs followed by a digit or space and digit', () => { + const content = 'Price is $50 and $ 100'; + const expected = 'Price is \\$50 and \\$ 100'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('handles strings with no content', () => { + const content = ''; + expect(processLaTeX(content)).toBe(''); + }); + + test('does not alter already valid inline Markdown LaTeX', () => { + const content = 'This is a valid inline LaTeX: $x^2 + y^2 = z^2$'; + expect(processLaTeX(content)).toBe(content); + }); + + test('does not alter already valid block Markdown LaTeX', () => { + const content = 'This is a valid block LaTeX: $$E = mc^2$$'; + expect(processLaTeX(content)).toBe(content); + }); + + test('correctly processes a mix of valid Markdown LaTeX and LaTeX patterns', () => { + const content = 'Valid $a + b = c$ and LaTeX to convert \\(x^2 + y^2 = z^2\\)'; + const expected = 'Valid $a + b = c$ and LaTeX to convert $x^2 + y^2 = z^2$'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('correctly handles strings with LaTeX and non-LaTeX dollar signs', () => { + const content = 'Price $100 and LaTeX \\(x^2 + y^2 = z^2\\)'; + const expected = 'Price \\$100 and LaTeX $x^2 + y^2 = z^2$'; + expect(processLaTeX(content)).toBe(expected); + }); + + test('ignores non-LaTeX content enclosed in dollar signs', () => { + const content = 'This is not LaTeX: $This is just text$'; + expect(processLaTeX(content)).toBe(content); + }); + + test('correctly processes complex block LaTeX with line breaks', () => { + const complexBlockLatex = `Certainly! Here's an example of a mathematical formula written in LaTeX: + + \\[ + \\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2 + \\] + + This formula represents the sum of the squares of the ratios of \\(x\\) to \\(y\\) for \\(n\\) terms, where \\(x_i\\) and \\(y_i\\) represent the values of \\(x\\) and \\(y\\) for each term. + + LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`; + const expectedOutput = `Certainly! Here's an example of a mathematical formula written in LaTeX: + + $$ + \\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2 + $$ + + This formula represents the sum of the squares of the ratios of $x$ to $y$ for $n$ terms, where $x_i$ and $y_i$ represent the values of $x$ and $y$ for each term. + + LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`; + expect(processLaTeX(complexBlockLatex)).toBe(expectedOutput); + }); +}); diff --git a/client/src/utils/latex.ts b/client/src/utils/latex.ts new file mode 100644 index 00000000000..209859e95b6 --- /dev/null +++ b/client/src/utils/latex.ts @@ -0,0 +1,24 @@ +// Regex to check if the processed content contains any potential LaTeX patterns +const containsLatexRegex = + /\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/; +// Regex for inline and block LaTeX expressions +const inlineLatex = new RegExp(/\\\((.+?)\\\)/, 'g'); +// const blockLatex = new RegExp(/\\\[(.*?)\\\]/, 'gs'); +const blockLatex = new RegExp(/\\\[(.*?[^\\])\\\]/, 'gs'); + +export const processLaTeX = (content: string) => { + // Escape dollar signs followed by a digit or space and digit + let processedContent = content.replace(/(\$)(?=\s?\d)/g, '\\$'); + + // If no LaTeX patterns are found, return the processed content + if (!containsLatexRegex.test(processedContent)) { + return processedContent; + } + + // Convert LaTeX expressions to a markdown compatible format + processedContent = processedContent + .replace(inlineLatex, (match: string, equation: string) => `$${equation}$`) // Convert inline LaTeX + .replace(blockLatex, (match: string, equation: string) => `$$${equation}$$`); // Convert block LaTeX + + return processedContent; +}; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a7cc732d16c..145fc124524 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -212,3 +212,25 @@ export enum ImageDetailCost { */ ADDITIONAL = 85, } + +/** + * Tab values for Settings Dialog + */ +export enum SettingsTabValues { + /** + * Tab for General Settings + */ + GENERAL = 'general', + /** + * Tab for Beta Features + */ + BETA = 'beta', + /** + * Tab for Data Controls + */ + DATA = 'data', + /** + * Tab for Account Settings + */ + ACCOUNT = 'account', +}