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"; import debounce from "lodash.debounce"; import useUser from "@/hooks/useUser"; import Chartable from "./Chartable"; import Workspace from "@/models/workspace"; 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"; import { useTranslation } from "react-i18next"; import { useChatMessageAlignment } from "@/hooks/useChatMessageAlignment"; export default function ChatHistory({ history = [], workspace, sendCommand, updateHistory, regenerateAssistantMessage, hasAttachments = false, }) { const { t } = useTranslation(); const lastScrollTopRef = useRef(0); const { user } = useUser(); const { threadSlug = null } = useParams(); const { showing, showModal, hideModal } = useManageWorkspaceModal(); const [isAtBottom, setIsAtBottom] = useState(true); const chatHistoryRef = useRef(null); const [isUserScrolling, setIsUserScrolling] = useState(false); const isStreaming = history[history.length - 1]?.animate; const { showScrollbar } = Appearance.getSettings(); const { textSizeClass } = useTextSize(); const { getMessageAlignment } = useChatMessageAlignment(); useEffect(() => { if (!isUserScrolling && (isAtBottom || isStreaming)) { scrollToBottom(false); // Use instant scroll for auto-scrolling } }, [history, isAtBottom, isStreaming, isUserScrolling]); const handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; const isBottom = scrollHeight - scrollTop === clientHeight; // Detect if this is a user-initiated scroll if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) { setIsUserScrolling(!isBottom); } setIsAtBottom(isBottom); lastScrollTopRef.current = scrollTop; }; const debouncedScroll = debounce(handleScroll, 100); useEffect(() => { const chatHistoryElement = chatHistoryRef.current; if (chatHistoryElement) { chatHistoryElement.addEventListener("scroll", debouncedScroll); return () => chatHistoryElement.removeEventListener("scroll", debouncedScroll); } }, []); const scrollToBottom = (smooth = false) => { if (chatHistoryRef.current) { chatHistoryRef.current.scrollTo({ top: chatHistoryRef.current.scrollHeight, // Smooth is on when user clicks the button but disabled during auto scroll // We must disable this during auto scroll because it causes issues with // detecting when we are at the bottom of the chat. ...(smooth ? { behavior: "smooth" } : {}), }); } }; const handleSendSuggestedMessage = (heading, message) => { sendCommand({ text: `${heading} ${message}`, autoSubmit: true }); }; const saveEditedMessage = async ({ editedMessage, chatId, role, attachments = [], }) => { if (!editedMessage) return; // Don't save empty edits. // if the edit was a user message, we will auto-regenerate the response and delete all // messages post modified message if (role === "user") { // remove all messages after the edited message // technically there are two chatIds per-message pair, this will split the first. const updatedHistory = history.slice( 0, history.findIndex((msg) => msg.chatId === chatId) + 1 ); // update last message in history to edited message updatedHistory[updatedHistory.length - 1].content = editedMessage; // remove all edited messages after the edited message in backend await Workspace.deleteEditedChats(workspace.slug, threadSlug, chatId); sendCommand({ text: editedMessage, autoSubmit: true, history: updatedHistory, attachments, }); return; } // If role is an assistant we simply want to update the comment and save on the backend as an edit. if (role === "assistant") { const updatedHistory = [...history]; const targetIdx = history.findIndex( (msg) => msg.chatId === chatId && msg.role === role ); if (targetIdx < 0) return; updatedHistory[targetIdx].content = editedMessage; updateHistory(updatedHistory); await Workspace.updateChatResponse( workspace.slug, threadSlug, chatId, editedMessage ); return; } }; const forkThread = async (chatId) => { const newThreadSlug = await Workspace.forkThread( workspace.slug, threadSlug, chatId ); window.location.href = paths.workspace.thread( workspace.slug, newThreadSlug ); }; const compiledHistory = useMemo( () => buildMessages({ workspace, history, regenerateAssistantMessage, saveEditedMessage, forkThread, getMessageAlignment, }), [ workspace, history, regenerateAssistantMessage, saveEditedMessage, forkThread, ] ); const lastMessageInfo = useMemo(() => getLastMessageInfo(history), [history]); const renderStatusResponse = useCallback( (item, index) => { const hasSubsequentMessages = index < compiledHistory.length - 1; return ( ); }, [compiledHistory.length, lastMessageInfo] ); if (history.length === 0 && !hasAttachments) { return (

{t("chat_window.welcome")}

{!user || user.role !== "default" ? (

{t("chat_window.get_started")} {t("chat_window.upload")} {t("chat_window.or")}{" "} {t("chat_window.send_chat")}

) : (

{t("chat_window.get_started_default")}{" "} {t("chat_window.send_chat")}

)}
{showing && ( )}
); } return (
{compiledHistory.map((item, index) => Array.isArray(item) ? renderStatusResponse(item, index) : item )} {showing && ( )} {!isAtBottom && (
{ scrollToBottom(true); setIsUserScrolling(false); }} >
)}
); } 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; return (
{suggestions.map((suggestion, index) => ( ))}
); } /** * 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. * @param {Function} param0.getMessageAlignment - The function to get the alignment of the message (returns class). * @returns {Array} The compiled history of messages. */ function buildMessages({ history, workspace, regenerateAssistantMessage, saveEditedMessage, forkThread, getMessageAlignment, }) { 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( ); } else if (isLastBotReply && props.animate) { acc.push( ); } else { acc.push( ); } return acc; }, []); }