"use client";
import styles from "./chatHistory.module.css";
import { useRef, useEffect, useState } from "react";
import ChatMessage, {
ChatHistoryData,
StreamMessage,
TrainOfThought,
} from "../chatMessage/chatMessage";
import { ScrollArea } from "@/components/ui/scroll-area";
import { InlineLoading } from "../loading/loading";
import { Lightbulb } from "@phosphor-icons/react";
import ProfileCard from "../profileCard/profileCard";
import { getIconFromIconName } from "@/app/common/iconUtils";
import { AgentData } from "@/app/agents/page";
import React from "react";
interface ChatResponse {
status: string;
response: ChatHistoryData;
}
interface ChatHistory {
[key: string]: string;
}
interface ChatHistoryProps {
conversationId: string;
setTitle: (title: string) => void;
incomingMessages?: StreamMessage[];
pendingMessage?: string;
publicConversationSlug?: string;
setAgent: (agent: AgentData) => void;
}
function constructTrainOfThought(
trainOfThought: string[],
lastMessage: boolean,
agentColor: string,
key: string,
completed: boolean = false,
) {
const lastIndex = trainOfThought.length - 1;
return (
{!completed && }
{trainOfThought.map((train, index) => (
))}
);
}
export default function ChatHistory(props: ChatHistoryProps) {
const [data, setData] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const ref = useRef(null);
const chatHistoryRef = useRef(null);
const sentinelRef = useRef(null);
const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState<
number | null
>(null);
const [fetchingData, setFetchingData] = useState(false);
const [isMobileWidth, setIsMobileWidth] = useState(false);
useEffect(() => {
window.addEventListener("resize", () => {
setIsMobileWidth(window.innerWidth < 768);
});
setIsMobileWidth(window.innerWidth < 768);
}, []);
useEffect(() => {
// This function ensures that scrolling to bottom happens after the data (chat messages) has been updated and rendered the first time.
const scrollToBottomAfterDataLoad = () => {
// Assume the data is loading in this scenario.
if (!data?.chat.length) {
setTimeout(() => {
scrollToBottom();
}, 500);
}
};
if (currentPage < 2) {
// Call the function defined above.
scrollToBottomAfterDataLoad();
}
}, [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);
setCurrentPage((prev) => prev + 1);
}
},
{ 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);
}
}
if (isUserAtBottom()) {
scrollToBottom();
}
}, [props.incomingMessages]);
function fetchMoreMessages(currentPage: number) {
if (!hasMoreMessages || fetchingData) return;
const nextPage = currentPage + 1;
let conversationFetchURL = "";
if (props.conversationId) {
conversationFetchURL = `/api/chat/history?client=web&conversation_id=${props.conversationId}&n=${10 * nextPage}`;
} else if (props.publicConversationSlug) {
conversationFetchURL = `/api/chat/share/history?client=web&public_conversation_slug=${props.publicConversationSlug}&n=${10 * nextPage}`;
} else {
return;
}
fetch(conversationFetchURL)
.then((response) => response.json())
.then((chatData: ChatResponse) => {
props.setTitle(chatData.response.slug);
if (
chatData &&
chatData.response &&
chatData.response.chat &&
chatData.response.chat.length > 0
) {
if (chatData.response.chat.length === data?.chat.length) {
setHasMoreMessages(false);
setFetchingData(false);
return;
}
props.setAgent(chatData.response.agent);
setData(chatData.response);
if (currentPage < 2) {
scrollToBottom();
}
setFetchingData(false);
} 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,
};
props.setAgent(chatData.response.agent);
setData(chatMetadata);
}
setHasMoreMessages(false);
setFetchingData(false);
}
})
.catch((err) => {
console.error(err);
window.location.href = "/";
});
}
const scrollToBottom = () => {
if (chatHistoryRef.current) {
chatHistoryRef.current.scrollIntoView(false);
}
};
const isUserAtBottom = () => {
if (!chatHistoryRef.current) return false;
// NOTE: This isn't working. It always seems to return true. This is because
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current as HTMLDivElement;
const threshold = 25; // pixels from the bottom
// Considered at the bottom if within threshold pixels from the bottom
return scrollTop + clientHeight >= scrollHeight - threshold;
};
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`;
return data.agent.name;
}
function constructAgentPersona() {
if (!data || !data.agent || !data.agent.persona) return ``;
return data.agent.persona;
}
if (!props.conversationId && !props.publicConversationSlug) {
return null;
}
return (
{fetchingData && (
)}
{data &&
data.chat &&
data.chat.map((chatMessage, index) => (
))}
{props.incomingMessages &&
props.incomingMessages.map((message, index) => {
return (
{message.trainOfThought &&
constructTrainOfThought(
message.trainOfThought,
index === incompleteIncomingMessageIndex,
data?.agent.color || "orange",
`${index}trainOfThought`,
message.completed,
)}
);
})}
{props.pendingMessage && (
)}
{data && (
)
}
description={constructAgentPersona()}
/>
)}
);
}