diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c06fb35ad1ea0d493668aceef223d3e59e54854d --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx @@ -0,0 +1,113 @@ +import React, { useState } from "react"; +import { + CaretDown, + CircleNotch, + Check, + CheckCircle, +} from "@phosphor-icons/react"; + +export default function StatusResponse({ + messages = [], + isThinking = false, + showCheckmark = false, +}) { + const [isExpanded, setIsExpanded] = useState(false); + const currentThought = messages[messages.length - 1]; + const previousThoughts = messages.slice(0, -1); + + function handleExpandClick() { + if (!previousThoughts.length > 0) return; + setIsExpanded(!isExpanded); + } + + return ( + <div className="flex justify-center items-end w-full"> + <div className="py-2 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col relative"> + <div + onClick={handleExpandClick} + className={`${!previousThoughts?.length ? "cursor-text" : "cursor-pointer hover:bg-theme-sidebar-item-hover transition-all duration-200"} bg-theme-bg-chat-input rounded-full py-2 px-4 flex items-center gap-x-2 border border-theme-sidebar-border`} + > + {isThinking ? ( + <CircleNotch + className="w-4 h-4 text-theme-text-secondary animate-spin" + aria-label="Agent is thinking..." + /> + ) : showCheckmark ? ( + <CheckCircle + className="w-4 h-4 text-green-400 transition-all duration-300" + aria-label="Thought complete" + /> + ) : null} + <div className="flex-1 overflow-hidden"> + <span + key={currentThought.content} + className="text-xs text-theme-text-secondary font-mono inline-block w-full animate-thoughtTransition" + > + {currentThought.content} + </span> + </div> + <div className="flex items-center gap-x-2"> + {previousThoughts?.length > 0 && ( + <div + 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" : ""}`} + /> + </div> + )} + </div> + </div> + + {/* Previous thoughts dropdown */} + {previousThoughts?.length > 0 && ( + <div + key={`cot-list-${currentThought.uuid}`} + className={`mt-2 bg-theme-bg-chat-input backdrop-blur-sm rounded-lg overflow-hidden transition-all duration-300 border border-theme-sidebar-border ${ + isExpanded ? "max-h-[300px] opacity-100" : "max-h-0 opacity-0" + }`} + > + <div className="p-2"> + {previousThoughts.map((thought, index) => ( + <div + key={`cot-${thought.uuid || index}`} + className="flex gap-x-2" + > + <p className="text-xs text-theme-text-secondary font-mono"> + {index + 1}/{previousThoughts.length} + </p> + <div + className="flex items-center gap-x-3 p-2 animate-fadeUpIn" + style={{ animationDelay: `${index * 50}ms` }} + > + <span className="text-xs text-theme-text-secondary font-mono"> + {thought.content} + </span> + </div> + </div> + ))} + {/* Append current thought to the end */} + <div key={`cot-${currentThought.uuid}`} className="flex gap-x-2"> + <p className="text-xs text-theme-text-secondary font-mono"> + {previousThoughts.length + 1}/{previousThoughts.length + 1} + </p> + <div className="flex items-center gap-x-3 p-2 animate-fadeUpIn"> + <span className="text-xs text-theme-text-secondary font-mono"> + {currentThought.content} + </span> + </div> + </div> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 9bf8eeebbef4a007740645bf0dfd74cd1ca11347..819a6f1c261481a268005c16616d33777260c798 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -1,6 +1,7 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import HistoricalMessage from "./HistoricalMessage"; import PromptReply from "./PromptReply"; +import StatusResponse from "./StatusResponse"; import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace"; import ManageWorkspace from "../../../Modals/ManageWorkspace"; import { ArrowDown } from "@phosphor-icons/react"; @@ -12,6 +13,7 @@ import { useParams } from "react-router-dom"; import paths from "@/utils/paths"; import Appearance from "@/models/appearance"; import useTextSize from "@/hooks/useTextSize"; +import { v4 } from "uuid"; export default function ChatHistory({ history = [], @@ -174,63 +176,52 @@ export default function ChatHistory({ ); } + const compiledHistory = useMemo( + () => + buildMessages({ + workspace, + history, + regenerateAssistantMessage, + saveEditedMessage, + forkThread, + }), + [ + workspace, + history, + regenerateAssistantMessage, + saveEditedMessage, + forkThread, + ] + ); + const lastMessageInfo = useMemo(() => getLastMessageInfo(history), [history]); + const renderStatusResponse = useCallback( + (item, index) => { + const hasSubsequentMessages = index < compiledHistory.length - 1; + return ( + <StatusResponse + key={`status-group-${index}`} + messages={item} + isThinking={!hasSubsequentMessages && lastMessageInfo.isAnimating} + showCheckmark={ + hasSubsequentMessages || + (!lastMessageInfo.isAnimating && !lastMessageInfo.isStatusResponse) + } + /> + ); + }, + [compiledHistory.length, lastMessageInfo] + ); + return ( <div - className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col justify-start ${ - showScrollbar ? "show-scrollbar" : "no-scroll" - }`} + className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col justify-start ${showScrollbar ? "show-scrollbar" : "no-scroll"}`} id="chat-history" ref={chatHistoryRef} onScroll={handleScroll} > - {history.map((props, index) => { - const isLastBotReply = - index === history.length - 1 && props.role === "assistant"; - - if (props?.type === "statusResponse" && !!props.content) { - return <StatusResponse key={props.uuid} props={props} />; - } - - if (props.type === "rechartVisualize" && !!props.content) { - return ( - <Chartable key={props.uuid} workspace={workspace} props={props} /> - ); - } - - if (isLastBotReply && props.animate) { - return ( - <PromptReply - key={props.uuid} - uuid={props.uuid} - reply={props.content} - pending={props.pending} - sources={props.sources} - error={props.error} - workspace={workspace} - closed={props.closed} - /> - ); - } - - return ( - <HistoricalMessage - key={index} - message={props.content} - role={props.role} - workspace={workspace} - sources={props.sources} - feedbackScore={props.feedbackScore} - chatId={props.chatId} - error={props.error} - attachments={props.attachments} - regenerateMessage={regenerateAssistantMessage} - isLastMessage={isLastBotReply} - saveEditedMessage={saveEditedMessage} - forkThread={forkThread} - metrics={props.metrics} - /> - ); - })} + {compiledHistory.map((item, index) => + Array.isArray(item) ? renderStatusResponse(item, index) : item + )} {showing && ( <ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} /> )} @@ -253,21 +244,13 @@ export default function ChatHistory({ ); } -function StatusResponse({ props }) { - return ( - <div className="flex justify-center items-end w-full"> - <div className="py-2 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col"> - <div className="flex gap-x-5"> - <span - className={`text-xs inline-block p-2 rounded-lg text-white/60 font-mono whitespace-pre-line`} - > - {props.content} - </span> - </div> - </div> - </div> - ); -} +const getLastMessageInfo = (history) => { + const lastMessage = history?.[history.length - 1] || {}; + return { + isAnimating: lastMessage?.animate, + isStatusResponse: lastMessage?.type === "statusResponse", + }; +}; function WorkspaceChatSuggestions({ suggestions = [], sendSuggestion }) { if (suggestions.length === 0) return null; @@ -286,3 +269,78 @@ function WorkspaceChatSuggestions({ suggestions = [], sendSuggestion }) { </div> ); } + +/** + * Builds the history of messages for the chat. + * This is mostly useful for rendering the history in a way that is easy to understand. + * as well as compensating for agent thinking and other messages that are not part of the history, but + * are still part of the chat. + * + * @param {Object} param0 - The parameters for building the messages. + * @param {Array} param0.history - The history of messages. + * @param {Object} param0.workspace - The workspace object. + * @param {Function} param0.regenerateAssistantMessage - The function to regenerate the assistant message. + * @param {Function} param0.saveEditedMessage - The function to save the edited message. + * @param {Function} param0.forkThread - The function to fork the thread. + * @returns {Array} The compiled history of messages. + */ +function buildMessages({ + history, + workspace, + regenerateAssistantMessage, + saveEditedMessage, + forkThread, +}) { + return history.reduce((acc, props, index) => { + const isLastBotReply = + index === history.length - 1 && props.role === "assistant"; + + if (props?.type === "statusResponse" && !!props.content) { + if (acc.length > 0 && Array.isArray(acc[acc.length - 1])) { + acc[acc.length - 1].push(props); + } else { + acc.push([props]); + } + return acc; + } + + if (props.type === "rechartVisualize" && !!props.content) { + acc.push( + <Chartable key={props.uuid} workspace={workspace} props={props} /> + ); + } else if (isLastBotReply && props.animate) { + acc.push( + <PromptReply + key={props.uuid || v4()} + uuid={props.uuid} + reply={props.content} + pending={props.pending} + sources={props.sources} + error={props.error} + workspace={workspace} + closed={props.closed} + /> + ); + } else { + acc.push( + <HistoricalMessage + key={index} + message={props.content} + role={props.role} + workspace={workspace} + sources={props.sources} + feedbackScore={props.feedbackScore} + chatId={props.chatId} + error={props.error} + attachments={props.attachments} + regenerateMessage={regenerateAssistantMessage} + isLastMessage={isLastBotReply} + saveEditedMessage={saveEditedMessage} + forkThread={forkThread} + metrics={props.metrics} + /> + ); + } + return acc; + }, []); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx index 3aa5dc6ccb1f953d998b049a8029ec7b7df3d36e..ea3cbdfb1f7fce0fc561c1137bac47f1290645fd 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatTooltips/index.jsx @@ -67,6 +67,12 @@ export function ChatTooltips() { delayShow={300} className="tooltip !text-xs" /> + <Tooltip + id="expand-cot" + place="bottom" + delayShow={300} + className="tooltip !text-xs" + /> </> ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 96d01d41cb1498f33e4b1013487ab387dbf72603..539b5866582af97f734f612b6e8643eab014a68e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -583,22 +583,6 @@ dialog::backdrop { animation: slideDown 0.3s ease-out forwards; } -@keyframes slideUp { - from { - max-height: 400px; - opacity: 1; - } - - to { - max-height: 0; - opacity: 0; - } -} - -.slide-up { - animation: slideUp 0.3s ease-out forwards; -} - .input-label { @apply text-[14px] font-bold text-white; } @@ -946,3 +930,51 @@ does not extend the close button beyond the viewport. */ .rti--container { @apply !bg-theme-settings-input-bg !text-white !placeholder-white !placeholder-opacity-60 !text-sm !rounded-lg !p-2.5; } + +@keyframes fadeUpIn { + 0% { + opacity: 0; + transform: translateY(5px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fadeUpIn { + animation: fadeUpIn 0.3s ease-out forwards; +} + +@keyframes bounce-subtle { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-2px); + } +} + +@keyframes thoughtTransition { + 0% { + opacity: 0; + transform: translateY(10px); + } + + 30% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-thoughtTransition { + animation: thoughtTransition 0.5s ease-out forwards; +} diff --git a/frontend/src/utils/chat/agent.js b/frontend/src/utils/chat/agent.js index babe55605f48dff91d0eaaeffdc89286171b7751..ad1193d304c953fe0d9095d99791bbab5f5fc2b4 100644 --- a/frontend/src/utils/chat/agent.js +++ b/frontend/src/utils/chat/agent.js @@ -99,7 +99,7 @@ export default function handleSocketResponse(event, setChatHistory) { sources: [], closed: true, error: null, - animate: false, + animate: data?.animate || false, pending: false, }, ]; diff --git a/frontend/src/utils/chat/index.js b/frontend/src/utils/chat/index.js index 86c1cd4208ca166f80f08ba70e6f669b1bdd5638..abde54fe9c2da03391c655cb3fcc8e6d62f3936e 100644 --- a/frontend/src/utils/chat/index.js +++ b/frontend/src/utils/chat/index.js @@ -17,6 +17,7 @@ export default function handleChat( sources = [], error, close, + animate = false, chatId = null, action = null, metrics = {}, @@ -34,7 +35,7 @@ export default function handleChat( sources, closed: true, error, - animate: false, + animate, pending: false, metrics, }, @@ -47,7 +48,7 @@ export default function handleChat( sources, closed: true, error, - animate: false, + animate, pending: false, metrics, }); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8ecdbcdc0a4d72f71b7c4af04ab28744ccfbe644..517af2ea167f7feb5765f019aa74093eec5e2df1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -141,7 +141,10 @@ export default { }, animation: { sweep: "sweep 0.5s ease-in-out", - "pulse-glow": "pulse-glow 1.5s infinite" + "pulse-glow": "pulse-glow 1.5s infinite", + 'fade-in': 'fade-in 0.3s ease-out', + 'slide-up': 'slide-up 0.4s ease-out forwards', + 'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite' }, keyframes: { sweep: { @@ -175,6 +178,18 @@ export default { boxShadow: "0 0 0 rgba(255, 255, 255, 0.0)", backgroundColor: "rgba(255, 255, 255, 0.0)" } + }, + 'fade-in': { + '0%': { opacity: '0' }, + '100%': { opacity: '1' } + }, + 'slide-up': { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' } + }, + 'bounce-subtle': { + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-2px)' } } } } diff --git a/server/utils/agents/aibitat/plugins/websocket.js b/server/utils/agents/aibitat/plugins/websocket.js index 8c8800ff3e25fbce03ada037a28bff5ac4f102ee..a253bd010e9459c519793210d74bcb1fd9958f2b 100644 --- a/server/utils/agents/aibitat/plugins/websocket.js +++ b/server/utils/agents/aibitat/plugins/websocket.js @@ -68,7 +68,11 @@ const websocket = { aibitat.introspect = (messageText) => { if (!introspection) return; // Dump thoughts when not wanted. socket.send( - JSON.stringify({ type: "statusResponse", content: messageText }) + JSON.stringify({ + type: "statusResponse", + content: messageText, + animate: true, + }) ); }; diff --git a/server/utils/agents/ephemeral.js b/server/utils/agents/ephemeral.js index d8258eca800664833c605d2ddc025bee732e4b0b..035de8ffe34b0faabd0789df6682ccbe8ed97d3c 100644 --- a/server/utils/agents/ephemeral.js +++ b/server/utils/agents/ephemeral.js @@ -412,6 +412,7 @@ class EphemeralEventListener extends EventEmitter { attachments: [], close: false, error: null, + animate: true, }); } @@ -423,6 +424,7 @@ class EphemeralEventListener extends EventEmitter { attachments: [], close: true, error: null, + animate: false, }); }; this.on("chunk", onChunkHandler); diff --git a/server/utils/chats/agents.js b/server/utils/chats/agents.js index cd127d07f25825beddd815a9ba4c374dbc92cd03..26de10e8ace16feca24548c94eb879ccec3e7793 100644 --- a/server/utils/chats/agents.js +++ b/server/utils/chats/agents.js @@ -33,6 +33,7 @@ async function grepAgents({ )} could not be called. Chat will be handled as default chat.`, sources: [], close: true, + animate: false, error: null, }); return; @@ -61,6 +62,7 @@ async function grepAgents({ sources: [], close: true, error: null, + animate: true, }); return true; }