LibreChat/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx

179 lines
5.4 KiB
TypeScript

import React, { memo, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useToastContext, useCodeBlockContext } from '~/Providers';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useFileDownload } from '~/data-provider';
import useLocalize from '~/hooks/useLocalize';
import { handleDoubleClick } from '~/utils';
import store from '~/store';
// Loading fallback component for lazy-loaded Mermaid diagrams
const MermaidLoadingFallback = memo(() => {
const localize = useLocalize();
return (
<div className="my-4 rounded-lg border border-border-light bg-surface-primary p-4 text-center text-text-secondary dark:border-border-heavy dark:bg-surface-primary-alt">
{localize('com_ui_loading_diagram')}
</div>
);
});
type TCodeProps = {
inline?: boolean;
className?: string;
children: React.ReactNode;
};
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
const isMath = lang === 'math';
const isMermaid = lang === 'mermaid';
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
const { getNextIndex, resetCounter } = useCodeBlockContext();
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
useEffect(() => {
resetCounter();
}, [children, resetCounter]);
if (isMath) {
return <>{children}</>;
} else if (isMermaid && typeof children === 'string') {
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
return (
<Suspense fallback={<MermaidLoadingFallback />}>
<SandpackMermaidDiagram content={children} />
</Suspense>
);
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
</code>
);
} else {
return (
<CodeBlock
lang={lang ?? 'text'}
codeChildren={children}
blockIndex={blockIndex}
allowExecution={canRunCode}
/>
);
}
});
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
const isMermaid = lang === 'mermaid';
if (lang === 'math') {
return children;
} else if (isMermaid && typeof children === 'string') {
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
return (
<Suspense fallback={<MermaidLoadingFallback />}>
<SandpackMermaidDiagram content={children} />
</Suspense>
);
} else if (typeof children === 'string' && children.split('\n').length === 1) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
</code>
);
} else {
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
}
});
type TAnchorProps = {
href: string;
children: React.ReactNode;
};
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const localize = useLocalize();
const {
file_id = '',
filename = '',
filepath,
} = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
const path = match[0];
const parts = path.split('/');
const name = parts.pop();
const file_id = parts.pop();
return { file_id, filename: name, filepath: path };
}
return { file_id: '', filename: '', filepath: '' };
}, [user?.id, href]);
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
if (!file_id || !filename) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
};
props.onClick = handleDownload;
props.target = '_blank';
return (
<a
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}
</a>
);
});
type TParagraphProps = {
children: React.ReactNode;
};
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});