diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 2d581e38dabd8a671a7ba1ae60f804999b29bec3..8dfcf872ce86655e03ac52a6bdd147e9339c183a 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 = ({ /> ) : ( <div className="break-words"> - <span - className="flex flex-col gap-y-1" - dangerouslySetInnerHTML={{ - __html: DOMPurify.sanitize(renderMarkdown(message)), - }} + <RenderChatContent + role={role} + message={message} + expanded={isLastMessage} /> <ChatAttachments attachments={attachments} /> </div> @@ -179,3 +184,62 @@ function ChatAttachments({ attachments = [] }) { </div> ); } + +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 ( + <span + className="flex flex-col gap-y-1" + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize(renderMarkdown(message)), + }} + /> + ); + 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 <thinking> and then <response>'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 && ( + <ThoughtChainComponent content={thoughtChain} expanded={expanded} /> + )} + <span + className="flex flex-col gap-y-1" + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize(renderMarkdown(msgToRender)), + }} + /> + </> + ); + }, + (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 a562a89811db92bb24a67750c49bdf45879d8a7d..71169bf4e0354c0eae843c3e071002b6e5178fcd 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 = ({ <div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col"> <div className="flex gap-x-5"> <WorkspaceProfileImage workspace={workspace} /> - <span - className="break-words" - dangerouslySetInnerHTML={{ __html: renderMarkdown(reply) }} + <RenderAssistantChatContent + key={`${uuid}-prompt-reply-content`} + message={reply} /> </div> <Citations sources={sources} /> @@ -88,4 +94,51 @@ export function WorkspaceProfileImage({ workspace }) { return <UserIcon user={{ uid: workspace.slug }} role="assistant" />; } +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 ( + <ThoughtChainComponent ref={thoughtChainRef} content="" expanded={true} /> + ); + + return ( + <div className="flex flex-col gap-y-1"> + {message.match(THOUGHT_REGEX_COMPLETE) && ( + <ThoughtChainComponent + ref={thoughtChainRef} + content="" + expanded={true} + /> + )} + <span + className="break-words" + dangerouslySetInnerHTML={{ __html: renderMarkdown(contentRef.current) }} + /> + </div> + ); +} + 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 0000000000000000000000000000000000000000..166b7c9ab405ced152a4928d85459e43f616d800 --- /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) => `</${keyword}\\s*(?:[^>]*?)?>`).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 ( + <div className="flex justify-start items-end transition-all duration-200 w-full md:max-w-[800px]"> + <div className="pb-2 w-full flex gap-x-5 flex-col relative"> + <div + style={{ + transition: "all 0.1s ease-in-out", + borderRadius: isExpanded || autoExpand ? "6px" : "24px", + }} + className={`${isExpanded || autoExpand ? "" : `${canExpand ? "hover:bg-theme-sidebar-item-hover" : ""}`} items-start bg-theme-bg-chat-input py-2 px-4 flex gap-x-2 border border-theme-sidebar-border`} + > + {isThinking || isComplete ? ( + <Brain + data-tooltip-id="cot-thinking" + data-tooltip-content={ + isThinking + ? "Model is thinking..." + : "Model has finished thinking" + } + className={`w-4 h-4 mt-1 ${isThinking ? "text-blue-500 animate-pulse" : "text-green-400"}`} + aria-label={ + isThinking + ? "Model is thinking..." + : "Model has finished thinking" + } + /> + ) : null} + <div className="flex-1 overflow-hidden"> + {!isExpanded && !autoExpand ? ( + <span + className="text-xs text-theme-text-secondary font-mono inline-block w-full" + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize( + truncate(tagStrippedContent, THOUGHT_PREVIEW_LENGTH) + ), + }} + /> + ) : ( + <span + className="text-xs text-theme-text-secondary font-mono inline-block w-full" + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize( + renderMarkdown(tagStrippedContent) + ), + }} + /> + )} + </div> + <div className="flex items-center gap-x-2"> + {!autoExpand && canExpand ? ( + <button + onClick={handleExpandClick} + data-tooltip-id="expand-cot" + data-tooltip-content={ + isExpanded ? "Hide thought chain" : "Show thought chain" + } + className="border-none text-theme-text-secondary hover:text-theme-text-primary transition-colors p-1 rounded-full hover:bg-theme-sidebar-item-hover" + aria-label={ + isExpanded ? "Hide thought chain" : "Show thought chain" + } + > + <CaretDown + className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`} + /> + </button> + ) : null} + </div> + </div> + </div> + </div> + ); + } +); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx index ea3cbdfb1f7fce0fc561c1137bac47f1290645fd..56ad1b8c4e52010e006e83f701614e86187e766b 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" /> + <Tooltip + id="cot-thinking" + place="bottom" + delayShow={500} + className="tooltip !text-xs" + /> </> ); }