| | 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); |
| | } |
| | }, [history, isAtBottom, isStreaming, isUserScrolling]); |
| |
|
| | const handleScroll = (e) => { |
| | const { scrollTop, scrollHeight, clientHeight } = e.target; |
| | const isBottom = scrollHeight - scrollTop === clientHeight; |
| |
|
| | |
| | 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 ? { behavior: "smooth" } : {}), |
| | }); |
| | } |
| | }; |
| |
|
| | const handleSendSuggestedMessage = (heading, message) => { |
| | sendCommand({ text: `${heading} ${message}`, autoSubmit: true }); |
| | }; |
| |
|
| | const saveEditedMessage = async ({ |
| | editedMessage, |
| | chatId, |
| | role, |
| | attachments = [], |
| | }) => { |
| | if (!editedMessage) return; |
| |
|
| | |
| | |
| | if (role === "user") { |
| | |
| | |
| | const updatedHistory = history.slice( |
| | 0, |
| | history.findIndex((msg) => msg.chatId === chatId) + 1 |
| | ); |
| |
|
| | |
| | updatedHistory[updatedHistory.length - 1].content = editedMessage; |
| | |
| | await Workspace.deleteEditedChats(workspace.slug, threadSlug, chatId); |
| | sendCommand({ |
| | text: editedMessage, |
| | autoSubmit: true, |
| | history: updatedHistory, |
| | attachments, |
| | }); |
| | return; |
| | } |
| |
|
| | |
| | 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 ( |
| | <StatusResponse |
| | key={`status-group-${index}`} |
| | messages={item} |
| | isThinking={!hasSubsequentMessages && lastMessageInfo.isAnimating} |
| | showCheckmark={ |
| | hasSubsequentMessages || |
| | (!lastMessageInfo.isAnimating && !lastMessageInfo.isStatusResponse) |
| | } |
| | /> |
| | ); |
| | }, |
| | [compiledHistory.length, lastMessageInfo] |
| | ); |
| |
|
| | if (history.length === 0 && !hasAttachments) { |
| | return ( |
| | <div className="flex flex-col h-full md:mt-0 pb-44 md:pb-40 w-full justify-end items-center"> |
| | <div className="flex flex-col items-center md:items-start md:max-w-[600px] w-full px-4"> |
| | <p className="text-white/60 text-lg font-base py-4"> |
| | {t("chat_window.welcome")} |
| | </p> |
| | {!user || user.role !== "default" ? ( |
| | <p className="w-full items-center text-white/60 text-lg font-base flex flex-col md:flex-row gap-x-1"> |
| | {t("chat_window.get_started")} |
| | <span |
| | className="underline font-medium cursor-pointer" |
| | onClick={showModal} |
| | > |
| | {t("chat_window.upload")} |
| | </span> |
| | {t("chat_window.or")}{" "} |
| | <b className="font-medium italic">{t("chat_window.send_chat")}</b> |
| | </p> |
| | ) : ( |
| | <p className="w-full items-center text-white/60 text-lg font-base flex flex-col md:flex-row gap-x-1"> |
| | {t("chat_window.get_started_default")}{" "} |
| | <b className="font-medium italic">{t("chat_window.send_chat")}</b> |
| | </p> |
| | )} |
| | <WorkspaceChatSuggestions |
| | suggestions={workspace?.suggestedMessages ?? []} |
| | sendSuggestion={handleSendSuggestedMessage} |
| | /> |
| | </div> |
| | {showing && ( |
| | <ManageWorkspace |
| | hideModal={hideModal} |
| | providedSlug={workspace.slug} |
| | /> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| | 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"}`} |
| | id="chat-history" |
| | ref={chatHistoryRef} |
| | onScroll={handleScroll} |
| | > |
| | {compiledHistory.map((item, index) => |
| | Array.isArray(item) ? renderStatusResponse(item, index) : item |
| | )} |
| | {showing && ( |
| | <ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} /> |
| | )} |
| | {!isAtBottom && ( |
| | <div className="fixed bottom-40 right-10 md:right-20 z-50 cursor-pointer animate-pulse"> |
| | <div className="flex flex-col items-center"> |
| | <div |
| | className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white" |
| | onClick={() => { |
| | scrollToBottom(true); |
| | setIsUserScrolling(false); |
| | }} |
| | > |
| | <ArrowDown weight="bold" className="text-white/60 w-5 h-5" /> |
| | </div> |
| | </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; |
| | return ( |
| | <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-theme-text-primary text-xs mt-10 w-full justify-center"> |
| | {suggestions.map((suggestion, index) => ( |
| | <button |
| | key={index} |
| | className="text-left p-2.5 rounded-xl bg-theme-sidebar-footer-icon hover:bg-theme-sidebar-footer-icon-hover border border-theme-border" |
| | onClick={() => sendSuggestion(suggestion.heading, suggestion.message)} |
| | > |
| | <p className="font-semibold">{suggestion.heading}</p> |
| | <p>{suggestion.message}</p> |
| | </button> |
| | ))} |
| | </div> |
| | ); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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( |
| | <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} |
| | alignmentCls={getMessageAlignment?.(props.role)} |
| | /> |
| | ); |
| | } |
| | return acc; |
| | }, []); |
| | } |
| |
|