mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 12:16:33 +01:00
chore: reorg. content files, add blinking cursor
This commit is contained in:
parent
04796824d5
commit
0cc4aea204
11 changed files with 7 additions and 159 deletions
57
client/src/components/Messages/Content/CodeBlock.jsx
Normal file
57
client/src/components/Messages/Content/CodeBlock.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
|
||||
const CodeBlock = ({ lang, codeChildren }) => {
|
||||
const codeRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-black">
|
||||
<CodeBar
|
||||
lang={lang}
|
||||
codeRef={codeRef}
|
||||
/>
|
||||
<div className="overflow-y-auto p-4">
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={`hljs !whitespace-pre language-${lang}`}
|
||||
>
|
||||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBar = React.memo(({ lang, codeRef }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
return (
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span className="">{lang}</span>
|
||||
<button
|
||||
className="ml-auto flex gap-2"
|
||||
onClick={async () => {
|
||||
const codeString = codeRef.current?.textContent;
|
||||
if (codeString)
|
||||
navigator.clipboard.writeText(codeString).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 3000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
Copy code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export default CodeBlock;
|
||||
85
client/src/components/Messages/Content/Content.jsx
Normal file
85
client/src/components/Messages/Content/Content.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import { langSubset } from '~/utils/languages';
|
||||
|
||||
const Content = React.memo(({ content }) => {
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset
|
||||
}
|
||||
]
|
||||
]}
|
||||
linkTarget="_new"
|
||||
components={{
|
||||
code,
|
||||
p,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const code = React.memo((props) => {
|
||||
const { inline, className, children } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{children}</code>;
|
||||
} else {
|
||||
return (
|
||||
<CodeBlock
|
||||
lang={lang || 'text'}
|
||||
codeChildren={children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const p = React.memo((props) => {
|
||||
return <p className="whitespace-pre-wrap ">{props?.children}</p>;
|
||||
});
|
||||
|
||||
const blinker = ({ node }) => {
|
||||
if (node.type === 'text' && node.value === '█') {
|
||||
return <span className="result-streaming">{node.value}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const em = React.memo(({ node, ...props }) => {
|
||||
if (
|
||||
props.children[0] &&
|
||||
typeof props.children[0] === 'string' &&
|
||||
props.children[0].startsWith('^')
|
||||
) {
|
||||
return <sup>{props.children[0].substring(1)}</sup>;
|
||||
}
|
||||
if (
|
||||
props.children[0] &&
|
||||
typeof props.children[0] === 'string' &&
|
||||
props.children[0].startsWith('~')
|
||||
) {
|
||||
return <sub>{props.children[0].substring(1)}</sub>;
|
||||
}
|
||||
return <i {...props} />;
|
||||
});
|
||||
|
||||
export default Content;
|
||||
37
client/src/components/Messages/Content/Embed.jsx
Normal file
37
client/src/components/Messages/Content/Embed.jsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
|
||||
const Embed = React.memo(({ children, lang = '', code, matched }) => {
|
||||
const [buttonText, setButtonText] = useState('Copy code');
|
||||
const isClicked = buttonText === 'Copy code';
|
||||
|
||||
const clickHandler = () => {
|
||||
navigator.clipboard.writeText(code.trim());
|
||||
setButtonText('Copied!');
|
||||
setTimeout(() => {
|
||||
setButtonText('Copy code');
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<div className="mb-4 rounded-md bg-black">
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span className="">{lang === 'javascript' && !matched ? '' : lang}</span>
|
||||
<button
|
||||
className="ml-auto flex gap-2"
|
||||
onClick={clickHandler}
|
||||
disabled={!isClicked}
|
||||
>
|
||||
{isClicked ? <Clipboard /> : <CheckMark />}
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">{children}</div>
|
||||
</div>
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
|
||||
export default Embed;
|
||||
32
client/src/components/Messages/Content/Highlight.jsx
Normal file
32
client/src/components/Messages/Content/Highlight.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import Highlighter from 'react-highlight';
|
||||
import hljs from 'highlight.js';
|
||||
import { languages } from '~/utils/languages';
|
||||
|
||||
const Highlight = React.memo(({ language, code }) => {
|
||||
const [highlightedCode, setHighlightedCode] = useState(code);
|
||||
const lang = language ? language : 'javascript';
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedCode(hljs.highlight(code, { language: lang }).value);
|
||||
}, [code, lang]);
|
||||
|
||||
return (
|
||||
<pre>
|
||||
{!highlightedCode ? (
|
||||
// <code className={`hljs !whitespace-pre language-${lang ? lang: 'javascript'}`}>
|
||||
<Highlighter className={`hljs !whitespace-pre language-${lang ? lang : 'javascript'}`}>
|
||||
{code}
|
||||
</Highlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`hljs language-${lang}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
)}
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
|
||||
export default Highlight;
|
||||
|
||||
15
client/src/components/Messages/Content/TabLink.jsx
Normal file
15
client/src/components/Messages/Content/TabLink.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function TabLink(a) {
|
||||
return (
|
||||
<a
|
||||
href={a.href}
|
||||
title={a.title}
|
||||
className={a.className}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{a.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
160
client/src/components/Messages/Content/TextWrapper.jsx
Normal file
160
client/src/components/Messages/Content/TextWrapper.jsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React from 'react';
|
||||
import TabLink from './TabLink';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import Embed from './Embed';
|
||||
import Highlight from './Highlight';
|
||||
import regexSplit from '~/utils/regexSplit';
|
||||
import { wrapperRegex } from '~/utils';
|
||||
const { codeRegex, inLineRegex, markupRegex, languageMatch, newLineMatch } = wrapperRegex;
|
||||
const mdOptions = {
|
||||
wrapper: React.Fragment,
|
||||
forceWrapper: true,
|
||||
overrides: {
|
||||
a: {
|
||||
component: TabLink,
|
||||
// props: {
|
||||
// className: 'foo'
|
||||
// }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inLineWrap = (parts) => {
|
||||
let previousElement = null;
|
||||
return parts.map((part, i) => {
|
||||
if (part.match(markupRegex)) {
|
||||
const codeElement = <code key={i}>{part.slice(1, -1)}</code>;
|
||||
if (previousElement && typeof previousElement !== 'string') {
|
||||
// Append code element as a child to previous non-code element
|
||||
previousElement = (
|
||||
<Markdown
|
||||
options={mdOptions}
|
||||
key={i}
|
||||
>
|
||||
{previousElement}
|
||||
{codeElement}
|
||||
</Markdown>
|
||||
);
|
||||
return previousElement;
|
||||
} else {
|
||||
return codeElement;
|
||||
}
|
||||
} else {
|
||||
previousElement = part;
|
||||
return previousElement;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function TextWrapper({ text, generateCursor }) {
|
||||
let embedTest = false;
|
||||
let result = null;
|
||||
|
||||
// to match unenclosed code blocks
|
||||
if (text.match(/```/g)?.length === 1) {
|
||||
embedTest = true;
|
||||
}
|
||||
|
||||
// match enclosed code blocks
|
||||
if (text.match(codeRegex)) {
|
||||
const parts = regexSplit(text);
|
||||
// console.log(parts);
|
||||
const codeParts = parts.map((part, i) => {
|
||||
if (part.match(codeRegex)) {
|
||||
let language = 'javascript';
|
||||
let matched = false;
|
||||
|
||||
if (part.match(languageMatch)) {
|
||||
language = part.match(languageMatch)[1].toLowerCase();
|
||||
part = part.replace(languageMatch, '```');
|
||||
matched = true;
|
||||
// highlight.js language validation
|
||||
// const validLanguage = languages.some((lang) => language === lang);
|
||||
// part = validLanguage ? part.replace(languageMatch, '```') : part;
|
||||
// language = validLanguage ? language : 'javascript';
|
||||
}
|
||||
|
||||
part = part.replace(newLineMatch, '```');
|
||||
|
||||
return (
|
||||
<Embed
|
||||
key={i}
|
||||
language={language}
|
||||
code={part.slice(3, -3)}
|
||||
matched={matched}
|
||||
>
|
||||
<Highlight
|
||||
language={language}
|
||||
code={part.slice(3, -3)}
|
||||
/>
|
||||
</Embed>
|
||||
);
|
||||
} else if (part.match(inLineRegex)) {
|
||||
const innerParts = part.split(inLineRegex);
|
||||
return inLineWrap(innerParts);
|
||||
} else {
|
||||
return (
|
||||
<Markdown
|
||||
options={mdOptions}
|
||||
key={i}
|
||||
>
|
||||
{part}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <>{codeParts}</>; // return the wrapped text
|
||||
} else if (embedTest) {
|
||||
const language = text.match(/```(\w+)/)?.[1].toLowerCase() || 'javascript';
|
||||
const parts = text.split(text.match(/```(\w+)/)?.[0] || '```');
|
||||
const codeParts = parts.map((part, i) => {
|
||||
if (i === 1) {
|
||||
part = part.replace(/^\n+/, '');
|
||||
|
||||
return (
|
||||
<Embed
|
||||
key={i}
|
||||
language={language}
|
||||
>
|
||||
<Highlight
|
||||
code={part}
|
||||
language={language}
|
||||
/>
|
||||
</Embed>
|
||||
);
|
||||
} else if (part.match(inLineRegex)) {
|
||||
const innerParts = part.split(inLineRegex);
|
||||
return inLineWrap(innerParts);
|
||||
} else {
|
||||
return (
|
||||
<Markdown
|
||||
options={mdOptions}
|
||||
key={i}
|
||||
>
|
||||
{part}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// return <>{codeParts}</>; // return the wrapped text
|
||||
result = <>{codeParts}</>;
|
||||
} else if (text.match(markupRegex)) {
|
||||
// map over the parts and wrap any text between tildes with <code> tags
|
||||
const parts = text.split(markupRegex);
|
||||
const codeParts = inLineWrap(parts);
|
||||
// return <>{codeParts}</>; // return the wrapped text
|
||||
result = <>{codeParts}</>;
|
||||
} else {
|
||||
// return <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
result = <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{result}
|
||||
{generateCursor()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
client/src/components/Messages/Content/Wrapper.jsx
Normal file
25
client/src/components/Messages/Content/Wrapper.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import TextWrapper from './TextWrapper';
|
||||
import Content from './Content';
|
||||
|
||||
const Wrapper = React.memo(({ text, generateCursor, isCreatedByUser, searchResult }) => {
|
||||
if (!isCreatedByUser && searchResult) {
|
||||
return (
|
||||
<Content
|
||||
content={text}
|
||||
generateCursor={generateCursor}
|
||||
/>
|
||||
);
|
||||
} else if (!isCreatedByUser && !searchResult) {
|
||||
return (
|
||||
<TextWrapper
|
||||
text={text}
|
||||
generateCursor={generateCursor}
|
||||
/>
|
||||
);
|
||||
} else if (isCreatedByUser) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Wrapper;
|
||||
Loading…
Add table
Add a link
Reference in a new issue