mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-06 01:31:49 +01:00
chore: reorganize client files for docker
This commit is contained in:
parent
affbaaf1a5
commit
f5e079742a
93 changed files with 178726 additions and 1 deletions
35
client/src/components/Messages/Embed.jsx
Normal file
35
client/src/components/Messages/Embed.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useState } from 'react';
|
||||
import Clipboard from '../svg/Clipboard';
|
||||
import CheckMark from '../svg/CheckMark';
|
||||
|
||||
export default function Embed({ children, language = '', 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="">{language === 'javascript' && !matched ? '' : language}</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>
|
||||
);
|
||||
}
|
||||
16
client/src/components/Messages/Highlight.jsx
Normal file
16
client/src/components/Messages/Highlight.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
export default function Highlight({language, code}) {
|
||||
const [highlightedCode, setHighlightedCode] = useState(code);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedCode(hljs.highlight(code, { language }).value);
|
||||
}, [code, language]);
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code className={`language-${language}`} dangerouslySetInnerHTML={{__html: highlightedCode}}/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
19
client/src/components/Messages/HoverButtons.jsx
Normal file
19
client/src/components/Messages/HoverButtons.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
// import Clipboard from '../svg/Clipboard';
|
||||
import EditIcon from '../svg/EditIcon';
|
||||
|
||||
export default function HoverButtons({ user }) {
|
||||
return (
|
||||
|
||||
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:top-0 lg:right-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
|
||||
{user && (
|
||||
<button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400">
|
||||
<EditIcon />
|
||||
</button>
|
||||
)}
|
||||
{/* <button className="rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400">
|
||||
<Clipboard />
|
||||
</button> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
client/src/components/Messages/Message.jsx
Normal file
111
client/src/components/Messages/Message.jsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import TextWrapper from './TextWrapper';
|
||||
import { useSelector } from 'react-redux';
|
||||
import GPTIcon from '../svg/GPTIcon';
|
||||
import BingIcon from '../svg/BingIcon';
|
||||
import HoverButtons from './HoverButtons';
|
||||
|
||||
export default function Message({
|
||||
sender,
|
||||
text,
|
||||
last = false,
|
||||
error = false,
|
||||
scrollToBottom
|
||||
}) {
|
||||
const { isSubmitting } = useSelector((state) => state.submit);
|
||||
const [abortScroll, setAbort] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const notUser = sender.toLowerCase() !== 'user';
|
||||
const blinker = isSubmitting && last && notUser;
|
||||
|
||||
useEffect(() => {
|
||||
if (blinker && !abortScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [isSubmitting, text, blinker, scrollToBottom, abortScroll]);
|
||||
|
||||
const handleWheel = () => {
|
||||
if (blinker) {
|
||||
setAbort(true);
|
||||
} else {
|
||||
setAbort(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOver = () => {
|
||||
setIsHovering(true);
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
setIsHovering(false);
|
||||
};
|
||||
|
||||
const props = {
|
||||
className:
|
||||
'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800'
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
chatgpt: 'rgb(16, 163, 127)',
|
||||
chatgptBrowser: 'rgb(25, 207, 207)',
|
||||
bingai: ''
|
||||
};
|
||||
|
||||
let icon = `${sender}:`;
|
||||
let backgroundColor = bgColors[sender];
|
||||
|
||||
if (notUser) {
|
||||
props.className =
|
||||
'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-[#444654]';
|
||||
}
|
||||
|
||||
if (notUser && backgroundColor || sender === 'bingai') {
|
||||
icon = (
|
||||
<div
|
||||
style={{ backgroundColor }}
|
||||
className="relative flex h-[30px] w-[30px] items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
{sender === 'bingai' ? <BingIcon /> : <GPTIcon />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const wrapText = (text) => <TextWrapper text={text} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
onWheel={handleWheel}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
>
|
||||
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<strong className="relative flex w-[30px] flex-col items-end text-right">
|
||||
{icon}
|
||||
</strong>
|
||||
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
|
||||
<div className="flex flex-grow flex-col gap-3">
|
||||
{error ? (
|
||||
<div className="flex flex min-h-[20px] flex-row flex-col items-start gap-4 gap-2 whitespace-pre-wrap text-red-500">
|
||||
<div className="rounded-md border border-red-500 bg-red-500/10 py-2 px-3 text-sm text-gray-600 dark:text-gray-100">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[20px] flex-col items-start gap-4 whitespace-pre-wrap">
|
||||
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
{notUser ? wrapText(text) : text}
|
||||
{blinker && <span className="result-streaming">█</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
{isHovering && <HoverButtons user={!notUser} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
client/src/components/Messages/ScrollToBottom.jsx
Normal file
32
client/src/components/Messages/ScrollToBottom.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ScrollToBottom({ scrollHandler}) {
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollHandler}
|
||||
className="absolute right-6 bottom-[124px] z-10 cursor-pointer rounded-full border border-gray-200 bg-gray-50 text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-200 md:bottom-[120px]"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="m-1 h-4 w-4"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
y1="5"
|
||||
x2="12"
|
||||
y2="19"
|
||||
/>
|
||||
<polyline points="19 12 12 19 5 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
137
client/src/components/Messages/TextWrapper.jsx
Normal file
137
client/src/components/Messages/TextWrapper.jsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
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 };
|
||||
|
||||
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 }) {
|
||||
let embedTest = false;
|
||||
|
||||
// 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
|
||||
} 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
|
||||
} else {
|
||||
return <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
}
|
||||
}
|
||||
91
client/src/components/Messages/index.jsx
Normal file
91
client/src/components/Messages/index.jsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import ScrollToBottom from './ScrollToBottom';
|
||||
import Message from './Message';
|
||||
|
||||
const Messages = ({ messages }) => {
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const scrollableRef = useRef(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const scrollable = scrollableRef.current;
|
||||
const hasScrollbar = scrollable.scrollHeight > scrollable.clientHeight;
|
||||
setShowScrollButton(hasScrollbar);
|
||||
}, 650);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setShowScrollButton(false);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
|
||||
const diff = Math.abs(scrollHeight - scrollTop);
|
||||
const bottom =
|
||||
diff === clientHeight || (diff <= clientHeight + 25 && diff >= clientHeight - 25);
|
||||
if (bottom) {
|
||||
setShowScrollButton(false);
|
||||
} else {
|
||||
setShowScrollButton(true);
|
||||
}
|
||||
};
|
||||
|
||||
let timeoutId = null;
|
||||
const debouncedHandleScroll = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(handleScroll, 100);
|
||||
};
|
||||
|
||||
const scrollHandler = (e) => {
|
||||
e.preventDefault();
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto "
|
||||
ref={scrollableRef}
|
||||
onScroll={debouncedHandleScroll}
|
||||
>
|
||||
{/* <div className="flex-1 overflow-hidden"> */}
|
||||
<div className="h-full dark:gpt-dark-gray">
|
||||
<div className="flex h-full flex-col items-center text-sm dark:gpt-dark-gray">
|
||||
{messages.map((message, i) => (
|
||||
<Message
|
||||
key={i}
|
||||
sender={message.sender}
|
||||
text={message.text}
|
||||
last={i === messages.length - 1}
|
||||
error={message.error ? true : false}
|
||||
scrollToBottom={i === messages.length - 1 ? scrollToBottom : null}
|
||||
/>
|
||||
))}
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() => showScrollButton && <ScrollToBottom scrollHandler={scrollHandler} />}
|
||||
</CSSTransition>
|
||||
|
||||
<div
|
||||
className="group h-32 w-full flex-shrink-0 dark:border-gray-900/50 dark:gpt-dark-gray md:h-48"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
Loading…
Add table
Add a link
Reference in a new issue