diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 2d581e38dab..8dfcf872ce8 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -10,6 +10,12 @@ import createDOMPurify from "dompurify"; import { EditMessageForm, useEditMessage } from "./Actions/EditMessage"; import { useWatchDeleteMessage } from "./Actions/DeleteMessage"; import TTSMessage from "./Actions/TTSButton"; +import { + THOUGHT_REGEX_CLOSE, + THOUGHT_REGEX_COMPLETE, + THOUGHT_REGEX_OPEN, + ThoughtChainComponent, +} from "../ThoughtContainer"; const DOMPurify = createDOMPurify(window); const HistoricalMessage = ({ @@ -97,11 +103,10 @@ const HistoricalMessage = ({ /> ) : (
-
@@ -179,3 +184,62 @@ function ChatAttachments({ attachments = [] }) { ); } + +const RenderChatContent = memo( + ({ role, message, expanded = false }) => { + // If the message is not from the assistant, we can render it directly + // as normal since the user cannot think (lol) + if (role !== "assistant") + return ( + + ); + let thoughtChain = null; + let msgToRender = message; + + // If the message is a perfect thought chain, we can render it directly + // Complete == open and close tags match perfectly. + if (message.match(THOUGHT_REGEX_COMPLETE)) { + thoughtChain = message.match(THOUGHT_REGEX_COMPLETE)?.[0]; + msgToRender = message.replace(THOUGHT_REGEX_COMPLETE, ""); + } + + // If the message is a thought chain but not a complete thought chain (matching opening tags but not closing tags), + // we can render it as a thought chain if we can at least find a closing tag + // This can occur when the assistant starts with and then 's later. + if ( + message.match(THOUGHT_REGEX_OPEN) && + message.match(THOUGHT_REGEX_CLOSE) + ) { + const closingTag = message.match(THOUGHT_REGEX_CLOSE)?.[0]; + const splitMessage = message.split(closingTag); + thoughtChain = splitMessage[0] + closingTag; + msgToRender = splitMessage[1]; + } + + return ( + <> + {thoughtChain && ( + + )} + + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.role === nextProps.role && + prevProps.message === nextProps.message && + prevProps.expanded === nextProps.expanded + ); + } +); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx index a562a89811d..71169bf4e03 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx @@ -1,8 +1,14 @@ -import { memo } from "react"; +import { memo, useRef, useEffect } from "react"; import { Warning } from "@phosphor-icons/react"; import UserIcon from "../../../../UserIcon"; import renderMarkdown from "@/utils/chat/markdown"; import Citations from "../Citation"; +import { + THOUGHT_REGEX_CLOSE, + THOUGHT_REGEX_COMPLETE, + THOUGHT_REGEX_OPEN, + ThoughtChainComponent, +} from "../ThoughtContainer"; const PromptReply = ({ uuid, @@ -61,9 +67,9 @@ const PromptReply = ({
-
@@ -88,4 +94,51 @@ export function WorkspaceProfileImage({ workspace }) { return ; } +function RenderAssistantChatContent({ message }) { + const contentRef = useRef(""); + const thoughtChainRef = useRef(null); + + useEffect(() => { + const thinking = + message.match(THOUGHT_REGEX_OPEN) && !message.match(THOUGHT_REGEX_CLOSE); + + if (thinking && thoughtChainRef.current) { + thoughtChainRef.current.updateContent(message); + return; + } + + const completeThoughtChain = message.match(THOUGHT_REGEX_COMPLETE)?.[0]; + const msgToRender = message.replace(THOUGHT_REGEX_COMPLETE, ""); + + if (completeThoughtChain && thoughtChainRef.current) { + thoughtChainRef.current.updateContent(completeThoughtChain); + } + + contentRef.current = msgToRender; + }, [message]); + + const thinking = + message.match(THOUGHT_REGEX_OPEN) && !message.match(THOUGHT_REGEX_CLOSE); + if (thinking) + return ( + + ); + + return ( +
+ {message.match(THOUGHT_REGEX_COMPLETE) && ( + + )} + +
+ ); +} + export default memo(PromptReply); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/ThoughtContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/ThoughtContainer/index.jsx new file mode 100644 index 00000000000..166b7c9ab40 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/ThoughtContainer/index.jsx @@ -0,0 +1,129 @@ +import { useState, forwardRef, useImperativeHandle } from "react"; +import renderMarkdown from "@/utils/chat/markdown"; +import { Brain, CaretDown } from "@phosphor-icons/react"; +import DOMPurify from "dompurify"; +import truncate from "truncate"; +import { isMobile } from "react-device-detect"; + +const THOUGHT_KEYWORDS = ["thought", "thinking", "think", "thought_chain"]; +const CLOSING_TAGS = [...THOUGHT_KEYWORDS, "response", "answer"]; +export const THOUGHT_REGEX_OPEN = new RegExp( + THOUGHT_KEYWORDS.map((keyword) => `<${keyword}\\s*(?:[^>]*?)?\\s*>`).join("|") +); +export const THOUGHT_REGEX_CLOSE = new RegExp( + CLOSING_TAGS.map((keyword) => `]*?)?>`).join("|") +); +export const THOUGHT_REGEX_COMPLETE = new RegExp( + THOUGHT_KEYWORDS.map( + (keyword) => + `<${keyword}\\s*(?:[^>]*?)?\\s*>[\\s\\S]*?<\\/${keyword}\\s*(?:[^>]*?)?>` + ).join("|") +); +const THOUGHT_PREVIEW_LENGTH = isMobile ? 25 : 50; + +/** + * Component to render a thought chain. + * @param {string} content - The content of the thought chain. + * @param {boolean} expanded - Whether the thought chain is expanded. + * @returns {JSX.Element} + */ +export const ThoughtChainComponent = forwardRef( + ({ content: initialContent, expanded }, ref) => { + const [content, setContent] = useState(initialContent); + const [isExpanded, setIsExpanded] = useState(expanded); + useImperativeHandle(ref, () => ({ + updateContent: (newContent) => { + setContent(newContent); + }, + })); + + const isThinking = + content.match(THOUGHT_REGEX_OPEN) && !content.match(THOUGHT_REGEX_CLOSE); + const isComplete = + content.match(THOUGHT_REGEX_COMPLETE) || + content.match(THOUGHT_REGEX_CLOSE); + const tagStrippedContent = content + .replace(THOUGHT_REGEX_OPEN, "") + .replace(THOUGHT_REGEX_CLOSE, ""); + const autoExpand = + isThinking && tagStrippedContent.length > THOUGHT_PREVIEW_LENGTH; + const canExpand = tagStrippedContent.length > THOUGHT_PREVIEW_LENGTH; + if (!content || !content.length) return null; + + function handleExpandClick() { + if (!canExpand) return; + setIsExpanded(!isExpanded); + } + + return ( +
+
+
+ {isThinking || isComplete ? ( + + ) : null} +
+ {!isExpanded && !autoExpand ? ( + + ) : ( + + )} +
+
+ {!autoExpand && canExpand ? ( + + ) : null} +
+
+
+
+ ); + } +); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx index ea3cbdfb1f7..56ad1b8c4e5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx @@ -73,6 +73,12 @@ export function ChatTooltips() { delayShow={300} className="tooltip !text-xs" /> + ); }