"use client"; import styles from "./chatHistory.module.css"; import { useRef, useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import ChatMessage, { ChatHistoryData, StreamMessage, TrainOfThought, TrainOfThoughtObject, } from "../chatMessage/chatMessage"; import TrainOfThoughtVideoPlayer from "../../../components/trainOfThoughtVideoPlayer/trainOfThoughtVideoPlayer"; import { ScrollArea } from "@/components/ui/scroll-area"; import { InlineLoading } from "../loading/loading"; import { Lightbulb, ArrowDown, CaretDown, CaretUp } from "@phosphor-icons/react"; import AgentProfileCard from "../profileCard/profileCard"; import { getIconFromIconName } from "@/app/common/iconUtils"; import { AgentData } from "@/app/components/agentCard/agentCard"; import React from "react"; import { useIsMobileWidth } from "@/app/common/utils"; import { Button } from "@/components/ui/button"; import { KhojLogo } from "../logo/khojLogo"; interface ChatResponse { status: string; response: ChatHistoryData; } interface ChatHistoryProps { conversationId: string; setTitle: (title: string) => void; pendingMessage?: string; incomingMessages?: StreamMessage[]; setIncomingMessages?: (incomingMessages: StreamMessage[]) => void; publicConversationSlug?: string; setAgent: (agent: AgentData) => void; customClassName?: string; setIsChatSideBarOpen?: (isOpen: boolean) => void; setIsOwner?: (isOwner: boolean) => void; onRetryMessage?: (query: string, turnId?: string) => void; } interface TrainOfThoughtFrame { text: string; image?: string; timestamp: number; } interface TrainOfThoughtGroup { type: "video" | "text"; frames?: TrainOfThoughtFrame[]; textEntries?: TrainOfThoughtObject[]; } interface TrainOfThoughtComponentProps { trainOfThought: string[] | TrainOfThoughtObject[]; lastMessage: boolean; agentColor: string; keyId: string; completed?: boolean; } function extractTrainOfThoughtGroups( trainOfThought?: TrainOfThoughtObject[], ): TrainOfThoughtGroup[] { if (!trainOfThought) return []; const groups: TrainOfThoughtGroup[] = []; let currentVideoFrames: TrainOfThoughtFrame[] = []; let currentTextEntries: TrainOfThoughtObject[] = []; trainOfThought.forEach((thought, index) => { let text = thought.data; let hasImage = false; // Extract screenshot image from the thought data try { const jsonMatch = text.match( /\{.*(\"action\": \"screenshot\"|\"type\": \"screenshot\"|\"image\": \"data:image\/.*\").*\}/, ); if (jsonMatch) { const jsonMessage = JSON.parse(jsonMatch[0]); if (jsonMessage.image) { hasImage = true; // Clean up the text to remove the JSON action text = text.replace(`:\n**Action**: ${jsonMatch[0]}`, ""); if (jsonMessage.text) { text += `\n\n${jsonMessage.text}`; } // If we have accumulated text entries, add them as a text group if (currentTextEntries.length > 0) { groups.push({ type: "text", textEntries: [...currentTextEntries], }); currentTextEntries = []; } // Add to current video frames currentVideoFrames.push({ text: text, image: jsonMessage.image, timestamp: index, }); } } } catch (e) { console.error("Failed to parse screenshot data", e); } if (!hasImage) { // If we have accumulated video frames, add them as a video group if (currentVideoFrames.length > 0) { groups.push({ type: "video", frames: [...currentVideoFrames], }); currentVideoFrames = []; } // Add to current text entries currentTextEntries.push(thought); } }); // Add any remaining frames/entries if (currentVideoFrames.length > 0) { groups.push({ type: "video", frames: currentVideoFrames, }); } if (currentTextEntries.length > 0) { groups.push({ type: "text", textEntries: currentTextEntries, }); } return groups; } function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) { const [collapsed, setCollapsed] = useState(props.completed); const [trainOfThoughtGroups, setTrainOfThoughtGroups] = useState([]); const variants = { open: { height: "auto", opacity: 1, transition: { duration: 0.3, ease: "easeOut" }, }, closed: { height: 0, opacity: 0, transition: { duration: 0.3, ease: "easeIn" }, }, } as const; useEffect(() => { if (props.completed) { setCollapsed(true); } }, [props.completed]); useEffect(() => { // Handle empty array case if (!props.trainOfThought || props.trainOfThought.length === 0) { setTrainOfThoughtGroups([]); return; } // Convert string array to TrainOfThoughtObject array if needed let trainOfThoughtObjects: TrainOfThoughtObject[]; if (typeof props.trainOfThought[0] === "string") { trainOfThoughtObjects = (props.trainOfThought as string[]).map((data, index) => ({ type: "text", data: data, })); } else { trainOfThoughtObjects = props.trainOfThought as TrainOfThoughtObject[]; } const groups = extractTrainOfThoughtGroups(trainOfThoughtObjects); setTrainOfThoughtGroups(groups); }, [props.trainOfThought]); return (
{!props.completed && } {props.completed && (collapsed ? ( ) : ( ))} {!collapsed && ( {trainOfThoughtGroups.map((group, groupIndex) => (
{group.type === "video" && group.frames && group.frames.length > 0 && ( )} {group.type === "text" && group.textEntries && group.textEntries.map((entry, entryIndex) => { const lastIndex = trainOfThoughtGroups.length - 1; const isLastGroup = groupIndex === lastIndex; const isLastEntry = entryIndex === group.textEntries!.length - 1; const isPrimaryEntry = isLastGroup && isLastEntry && props.lastMessage && !props.completed; return ( ); })}
))}
)}
); } export default function ChatHistory(props: ChatHistoryProps) { const [data, setData] = useState(null); const [currentPage, setCurrentPage] = useState(0); const [hasMoreMessages, setHasMoreMessages] = useState(true); const [currentTurnId, setCurrentTurnId] = useState(null); const sentinelRef = useRef(null); const scrollAreaRef = useRef(null); const scrollableContentWrapperRef = useRef(null); const latestUserMessageRef = useRef(null); const latestFetchedMessageRef = useRef(null); const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState< number | null >(null); const [fetchingData, setFetchingData] = useState(false); const [isNearBottom, setIsNearBottom] = useState(true); const isMobileWidth = useIsMobileWidth(); const scrollAreaSelector = "[data-radix-scroll-area-viewport]"; const fetchMessageCount = 10; const hasStartingMessage = localStorage.getItem("message"); useEffect(() => { const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); if (!scrollAreaEl) return; const detectIsNearBottom = () => { const { scrollTop, scrollHeight, clientHeight } = scrollAreaEl; const bottomThreshold = 50; // pixels from bottom const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); const isNearBottom = distanceFromBottom <= bottomThreshold; setIsNearBottom(isNearBottom); }; scrollAreaEl.addEventListener("scroll", detectIsNearBottom); detectIsNearBottom(); // Initial check return () => scrollAreaEl.removeEventListener("scroll", detectIsNearBottom); }, [scrollAreaRef]); // Auto scroll while incoming message is streamed useEffect(() => { if (props.incomingMessages && props.incomingMessages.length > 0 && isNearBottom) { scrollToBottom(true); } }, [props.incomingMessages, isNearBottom]); // ResizeObserver to handle content height changes (e.g., images loading) useEffect(() => { const contentWrapper = scrollableContentWrapperRef.current; const scrollViewport = scrollAreaRef.current?.querySelector(scrollAreaSelector); if (!contentWrapper || !scrollViewport) return; const observer = new ResizeObserver(() => { // Check current scroll position to decide if auto-scroll is warranted const { scrollTop, scrollHeight, clientHeight } = scrollViewport; const bottomThreshold = 50; const currentlyNearBottom = scrollHeight - (scrollTop + clientHeight) <= bottomThreshold; if (currentlyNearBottom) { // Only auto-scroll if there are incoming messages being processed if (props.incomingMessages && props.incomingMessages.length > 0) { const lastMessage = props.incomingMessages[props.incomingMessages.length - 1]; // If the last message is not completed, or it just completed (indicated by incompleteIncomingMessageIndex still being set) if ( !lastMessage.completed || (lastMessage.completed && incompleteIncomingMessageIndex !== null) ) { scrollToBottom(true); // Use instant scroll } } } }); observer.observe(contentWrapper); return () => observer.disconnect(); }, [props.incomingMessages, incompleteIncomingMessageIndex, scrollAreaRef]); // Dependencies // Scroll to most recent user message after the first page of chat messages is loaded. useEffect(() => { if (data && data.chat && data.chat.length > 0 && currentPage < 2) { requestAnimationFrame(() => { latestUserMessageRef.current?.scrollIntoView({ behavior: "auto", block: "start" }); }); } }, [data, currentPage]); useEffect(() => { if (!hasMoreMessages || fetchingData) return; // TODO: A future optimization would be to add a time to delay to re-enabling the intersection observer. const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMoreMessages) { setFetchingData(true); fetchMoreMessages(currentPage); } }, { threshold: 1.0 }, ); if (sentinelRef.current) { observer.observe(sentinelRef.current); } return () => observer.disconnect(); }, [hasMoreMessages, currentPage, fetchingData]); useEffect(() => { setHasMoreMessages(true); setFetchingData(false); setCurrentPage(0); setData(null); }, [props.conversationId]); useEffect(() => { if (props.incomingMessages) { const lastMessage = props.incomingMessages[props.incomingMessages.length - 1]; if (lastMessage && !lastMessage.completed) { setIncompleteIncomingMessageIndex(props.incomingMessages.length - 1); props.setTitle(lastMessage.rawQuery); // Store the turnId when we get it if (lastMessage.turnId) { setCurrentTurnId(lastMessage.turnId); } } } }, [props.incomingMessages]); const adjustScrollPosition = () => { const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); requestAnimationFrame(() => { // Snap scroll position to the latest fetched message ref latestFetchedMessageRef.current?.scrollIntoView({ behavior: "auto", block: "start" }); // Now scroll up smoothly to render user scroll action scrollAreaEl?.scrollBy({ behavior: "smooth", top: -150 }); }); }; function fetchMoreMessages(currentPage: number) { if (!hasMoreMessages || fetchingData) return; const nextPage = currentPage + 1; const maxMessagesToFetch = nextPage * fetchMessageCount; let conversationFetchURL = ""; if (props.conversationId) { conversationFetchURL = `/api/chat/history?client=web&conversation_id=${encodeURIComponent(props.conversationId)}&n=${maxMessagesToFetch}`; } else if (props.publicConversationSlug) { conversationFetchURL = `/api/chat/share/history?client=web&public_conversation_slug=${props.publicConversationSlug}&n=${maxMessagesToFetch}`; } else { return; } fetch(conversationFetchURL) .then((response) => response.json()) .then((chatData: ChatResponse) => { props.setTitle(chatData.response.slug); props.setIsOwner && props.setIsOwner(chatData?.response?.is_owner); if ( chatData && chatData.response && chatData.response.chat && chatData.response.chat.length > 0 ) { setCurrentPage(Math.ceil(chatData.response.chat.length / fetchMessageCount)); if (chatData.response.chat.length === data?.chat.length) { setHasMoreMessages(false); setFetchingData(false); return; } props.setAgent(chatData.response.agent); setData(chatData.response); setFetchingData(false); if (currentPage === 0) { scrollToBottom(true); } else { adjustScrollPosition(); } } else { if (chatData.response.agent && chatData.response.conversation_id) { const chatMetadata = { chat: [], agent: chatData.response.agent, conversation_id: chatData.response.conversation_id, slug: chatData.response.slug, is_owner: chatData.response.is_owner, }; props.setAgent(chatData.response.agent); setData(chatMetadata); if (props.setIsChatSideBarOpen) { if (!hasStartingMessage) { props.setIsChatSideBarOpen(true); } } } setHasMoreMessages(false); setFetchingData(false); } }) .catch((err) => { console.error(err); window.location.href = "/"; }); } const scrollToBottom = (instant: boolean = false) => { const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); requestAnimationFrame(() => { scrollAreaEl?.scrollTo({ top: scrollAreaEl.scrollHeight, behavior: instant ? "auto" : "smooth", }); }); // Optimistically set, the scroll listener will verify if ( instant || (scrollAreaEl && scrollAreaEl.scrollHeight - (scrollAreaEl.scrollTop + scrollAreaEl.clientHeight) < 5) ) { setIsNearBottom(true); } }; function constructAgentLink() { if (!data || !data.agent || !data.agent?.slug) return `/agents`; return `/agents?agent=${data.agent?.slug}`; } function constructAgentName() { if (!data || !data.agent || !data.agent?.name) return `Agent`; if (data.agent.is_hidden) return "Khoj"; return data.agent?.name; } function constructAgentPersona() { if (!data || !data.agent) { return `Your agent is no longer available. You will be reset to the default agent.`; } if (!data.agent?.persona) { return `You can set a persona for your agent in the Chat Options side panel.`; } return data.agent?.persona; } const handleDeleteMessage = (turnId?: string) => { if (!turnId) return; setData((prevData) => { if (!prevData || !turnId) return prevData; return { ...prevData, chat: prevData.chat.filter((msg) => msg.turnId !== turnId), }; }); // Update incoming messages if they exist if (props.incomingMessages && props.setIncomingMessages) { props.setIncomingMessages( props.incomingMessages.filter((msg) => msg.turnId !== turnId), ); } }; const handleRetryMessage = (query: string, turnId?: string) => { if (!query) return; // Delete the message from local state first if (turnId) { handleDeleteMessage(turnId); } // Then trigger the retry props.onRetryMessage?.(query, turnId); }; if (!props.conversationId && !props.publicConversationSlug) { return null; } return (
{/* Print-only header with conversation info */}

{data?.slug || "Conversation with Khoj"}

Agent: {constructAgentName()}


{fetchingData && }
{data && data.chat && data.chat.map((chatMessage, index) => ( {chatMessage.trainOfThought && chatMessage.by === "khoj" && ( )} ))} {props.incomingMessages && props.incomingMessages.map((message, index) => { const messageTurnId = message.turnId ?? currentTurnId ?? undefined; return ( {message.trainOfThought && message.trainOfThought.length > 0 && ( t.length).join("-")}`} keyId={`${index}trainOfThought`} completed={message.completed} /> )} ); })} {props.pendingMessage && ( )} {data && (
} description={constructAgentPersona()} />
)}
{!isNearBottom && ( )}
); }