| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- import React, { useMemo, useState } from 'react';
- import Markdown, { ExtraProps } from 'react-markdown';
- import remarkGfm from 'remark-gfm';
- import rehypeHightlight from 'rehype-highlight';
- import rehypeKatex from 'rehype-katex';
- import remarkMath from 'remark-math';
- import remarkBreaks from 'remark-breaks';
- import 'katex/dist/katex.min.css';
- import { classNames, copyStr } from '../utils/misc';
- import { ElementContent, Root } from 'hast';
- import { visit } from 'unist-util-visit';
- import { useAppContext } from '../utils/app.context';
- import { CanvasType } from '../utils/types';
- import { BtnWithTooltips } from '../utils/common';
- import { DocumentDuplicateIcon, PlayIcon } from '@heroicons/react/24/outline';
- export default function MarkdownDisplay({
- content,
- isGenerating,
- }: {
- content: string;
- isGenerating?: boolean;
- }) {
- const preprocessedContent = useMemo(
- () => preprocessLaTeX(content),
- [content]
- );
- return (
- <Markdown
- remarkPlugins={[remarkGfm, remarkMath, remarkBreaks]}
- rehypePlugins={[rehypeHightlight, rehypeKatex, rehypeCustomCopyButton]}
- components={{
- button: (props) => (
- <CodeBlockButtons
- {...props}
- isGenerating={isGenerating}
- origContent={preprocessedContent}
- />
- ),
- // note: do not use "pre", "p" or other basic html elements here, it will cause the node to re-render when the message is being generated (this should be a bug with react-markdown, not sure how to fix it)
- }}
- >
- {preprocessedContent}
- </Markdown>
- );
- }
- const CodeBlockButtons: React.ElementType<
- React.ClassAttributes<HTMLButtonElement> &
- React.HTMLAttributes<HTMLButtonElement> &
- ExtraProps & { origContent: string; isGenerating?: boolean }
- > = ({ node, origContent, isGenerating }) => {
- const { config } = useAppContext();
- const startOffset = node?.position?.start.offset ?? 0;
- const endOffset = node?.position?.end.offset ?? 0;
- const copiedContent = useMemo(
- () =>
- origContent
- .substring(startOffset, endOffset)
- .replace(/^```[^\n]+\n/g, '')
- .replace(/```$/g, ''),
- [origContent, startOffset, endOffset]
- );
- const codeLanguage = useMemo(
- () =>
- origContent
- .substring(startOffset, startOffset + 10)
- .match(/^```([^\n]+)\n/)?.[1] ?? '',
- [origContent, startOffset]
- );
- const canRunCode =
- !isGenerating &&
- config.pyIntepreterEnabled &&
- codeLanguage.startsWith('py');
- return (
- <div
- className={classNames({
- 'text-right sticky top-[7em] mb-2 mr-2 h-0': true,
- 'display-none': !node?.position,
- })}
- >
- <CopyButton
- className="badge btn-mini btn-soft shadow-sm"
- content={copiedContent}
- />
- {canRunCode && (
- <RunPyCodeButton
- className="badge btn-mini shadow-sm ml-2"
- content={copiedContent}
- />
- )}
- </div>
- );
- };
- export const CopyButton = ({
- content,
- className,
- }: {
- content: string;
- className?: string;
- }) => {
- const [copied, setCopied] = useState(false);
- return (
- <BtnWithTooltips
- className={className}
- onClick={() => {
- copyStr(content);
- setCopied(true);
- }}
- onMouseLeave={() => setCopied(false)}
- tooltipsContent={copied ? 'Copied!' : 'Copy'}
- >
- <DocumentDuplicateIcon className="h-4 w-4" />
- </BtnWithTooltips>
- );
- };
- export const RunPyCodeButton = ({
- content,
- className,
- }: {
- content: string;
- className?: string;
- }) => {
- const { setCanvasData } = useAppContext();
- return (
- <>
- <BtnWithTooltips
- className={className}
- onClick={() =>
- setCanvasData({
- type: CanvasType.PY_INTERPRETER,
- content,
- })
- }
- tooltipsContent="Run code"
- >
- <PlayIcon className="h-4 w-4" />
- </BtnWithTooltips>
- </>
- );
- };
- /**
- * This injects the "button" element before each "pre" element.
- * The actual button will be replaced with a react component in the MarkdownDisplay.
- * We don't replace "pre" node directly because it will cause the node to re-render, which causes this bug: https://github.com/ggerganov/llama.cpp/issues/9608
- */
- function rehypeCustomCopyButton() {
- return function (tree: Root) {
- visit(tree, 'element', function (node) {
- if (node.tagName === 'pre' && !node.properties.visited) {
- const preNode = { ...node };
- // replace current node
- preNode.properties.visited = 'true';
- node.tagName = 'div';
- node.properties = {};
- // add node for button
- const btnNode: ElementContent = {
- type: 'element',
- tagName: 'button',
- properties: {},
- children: [],
- position: node.position,
- };
- node.children = [btnNode, preNode];
- }
- });
- };
- }
- /**
- * The part below is copied and adapted from:
- * https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
- * (MIT License)
- */
- // 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');
- // Function to restore code blocks
- const restoreCodeBlocks = (content: string, codeBlocks: string[]) => {
- return content.replace(
- /<<CODE_BLOCK_(\d+)>>/g,
- (_, index) => codeBlocks[index]
- );
- };
- // Regex to identify code blocks and inline code
- const codeBlockRegex = /(```[\s\S]*?```|`.*?`)/g;
- export const processLaTeX = (_content: string) => {
- let content = _content;
- // Temporarily replace code blocks and inline code with placeholders
- const codeBlocks: string[] = [];
- let index = 0;
- content = content.replace(codeBlockRegex, (match) => {
- codeBlocks[index] = match;
- return `<<CODE_BLOCK_${index++}>>`;
- });
- // Escape dollar signs followed by a digit or space and digit
- let processedContent = content.replace(/(\$)(?=\s?\d)/g, '\\$');
- // If no LaTeX patterns are found, restore code blocks and return the processed content
- if (!containsLatexRegex.test(processedContent)) {
- return restoreCodeBlocks(processedContent, codeBlocks);
- }
- // Convert LaTeX expressions to a markdown compatible format
- processedContent = processedContent
- .replace(inlineLatex, (_: string, equation: string) => `$${equation}$`) // Convert inline LaTeX
- .replace(blockLatex, (_: string, equation: string) => `$$${equation}$$`); // Convert block LaTeX
- // Restore code blocks
- return restoreCodeBlocks(processedContent, codeBlocks);
- };
- /**
- * Preprocesses LaTeX content by replacing delimiters and escaping certain characters.
- *
- * @param content The input string containing LaTeX expressions.
- * @returns The processed string with replaced delimiters and escaped characters.
- */
- export function preprocessLaTeX(content: string): string {
- // Step 1: Protect code blocks
- const codeBlocks: string[] = [];
- content = content.replace(/(```[\s\S]*?```|`[^`\n]+`)/g, (_, code) => {
- codeBlocks.push(code);
- return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
- });
- // Step 2: Protect existing LaTeX expressions
- const latexExpressions: string[] = [];
- // Protect block math ($$...$$), \[...\], and \(...\) as before.
- content = content.replace(
- /(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\))/g,
- (match) => {
- latexExpressions.push(match);
- return `<<LATEX_${latexExpressions.length - 1}>>`;
- }
- );
- // Protect inline math ($...$) only if it does NOT match a currency pattern.
- // We assume a currency pattern is one where the inner content is purely numeric (with optional decimals).
- content = content.replace(/\$([^$]+)\$/g, (match, inner) => {
- if (/^\s*\d+(?:\.\d+)?\s*$/.test(inner)) {
- // This looks like a currency value (e.g. "$123" or "$12.34"),
- // so don't protect it.
- return match;
- } else {
- // Otherwise, treat it as a LaTeX expression.
- latexExpressions.push(match);
- return `<<LATEX_${latexExpressions.length - 1}>>`;
- }
- });
- // Step 3: Escape dollar signs that are likely currency indicators.
- // (Now that inline math is protected, this will only escape dollars not already protected)
- content = content.replace(/\$(?=\d)/g, '\\$');
- // Step 4: Restore LaTeX expressions
- content = content.replace(
- /<<LATEX_(\d+)>>/g,
- (_, index) => latexExpressions[parseInt(index)]
- );
- // Step 5: Restore code blocks
- content = content.replace(
- /<<CODE_BLOCK_(\d+)>>/g,
- (_, index) => codeBlocks[parseInt(index)]
- );
- // Step 6: Apply additional escaping functions
- content = escapeBrackets(content);
- content = escapeMhchem(content);
- return content;
- }
- export function escapeBrackets(text: string): string {
- const pattern =
- /(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g;
- return text.replace(
- pattern,
- (
- match: string,
- codeBlock: string | undefined,
- squareBracket: string | undefined,
- roundBracket: string | undefined
- ): string => {
- if (codeBlock != null) {
- return codeBlock;
- } else if (squareBracket != null) {
- return `$$${squareBracket}$$`;
- } else if (roundBracket != null) {
- return `$${roundBracket}$`;
- }
- return match;
- }
- );
- }
- export function escapeMhchem(text: string) {
- return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{');
- }
|