mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-03 21:29:08 +00:00
Working example of streaming, intersection observer, other UI updates
This commit is contained in:
@@ -20,3 +20,11 @@ div.agentIndicator a {
|
||||
div.agentIndicator {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
div.trainOfThought {
|
||||
border: 1px var(--border-color) solid;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin: 12px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import styles from './chatHistory.module.css';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
import ChatMessage, { ChatHistoryData, SingleChatMessage } from '../chatMessage/chatMessage';
|
||||
import ChatMessage, { ChatHistoryData, SingleChatMessage, StreamMessage, TrainOfThought } from '../chatMessage/chatMessage';
|
||||
|
||||
import ReferencePanel, { hasValidReferences} from '../referencePanel/referencePanel';
|
||||
import ReferencePanel, { hasValidReferences } from '../referencePanel/referencePanel';
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
@@ -29,43 +29,149 @@ interface ChatHistory {
|
||||
interface ChatHistoryProps {
|
||||
conversationId: string;
|
||||
setTitle: (title: string) => void;
|
||||
incomingMessages?: StreamMessage[];
|
||||
pendingMessage?: string;
|
||||
}
|
||||
|
||||
|
||||
function constructTrainOfThought(trainOfThought: string[], lastMessage: boolean, key: string) {
|
||||
const lastIndex = trainOfThought.length - 1;
|
||||
return (
|
||||
<div className={`${styles.trainOfThought}`} key={key}>
|
||||
{trainOfThought.map((train, index) => (
|
||||
<TrainOfThought message={train} primary={index === lastIndex && lastMessage} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function ChatHistory(props: ChatHistoryProps) {
|
||||
const [data, setData] = useState<ChatHistoryData | null>(null);
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chatHistoryRef = useRef(null);
|
||||
const chatHistoryRef = useRef<HTMLDivElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [showReferencePanel, setShowReferencePanel] = useState(true);
|
||||
const [referencePanelData, setReferencePanelData] = useState<SingleChatMessage | null>(null);
|
||||
const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// useEffect(() => {
|
||||
|
||||
fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=10`)
|
||||
// // TODO add intersection observer to load more messages incrementally using parameter n=. Right now, it loads all messages at once.
|
||||
|
||||
// fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}`)
|
||||
// .then(response => response.json())
|
||||
// .then((chatData: ChatResponse) => {
|
||||
// setLoading(false);
|
||||
|
||||
// // Render chat options, if any
|
||||
// if (chatData) {
|
||||
// setData(chatData.response);
|
||||
// props.setTitle(chatData.response.slug);
|
||||
// }
|
||||
// })
|
||||
// .catch(err => {
|
||||
// console.error(err);
|
||||
// return;
|
||||
// });
|
||||
// }, [props.conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("hasMoreMessages", hasMoreMessages);
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
console.log("entries intersection observer", entries);
|
||||
if (entries[0].isIntersecting && hasMoreMessages) {
|
||||
console.log("call fetchMoreMessages");
|
||||
fetchMoreMessages(currentPage);
|
||||
console.log("currentPage", currentPage);
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
}, { threshold: 1.0 });
|
||||
|
||||
if (sentinelRef.current) {
|
||||
console.log("observe sentinel");
|
||||
observer.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sentinelRef.current, hasMoreMessages, currentPage, props.conversationId]);
|
||||
|
||||
const fetchMoreMessages = (currentPage: number) => {
|
||||
if (!hasMoreMessages) return;
|
||||
|
||||
console.log("fetchMoreMessages", currentPage);
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=${10*nextPage}`)
|
||||
.then(response => response.json())
|
||||
.then((chatData: ChatResponse) => {
|
||||
setLoading(false);
|
||||
|
||||
// Render chat options, if any
|
||||
if (chatData) {
|
||||
console.log(chatData);
|
||||
if (chatData && chatData.response && chatData.response.chat.length > 0) {
|
||||
console.log(chatData);
|
||||
|
||||
if (chatData.response.chat.length === data?.chat.length) {
|
||||
setHasMoreMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
setData(chatData.response);
|
||||
props.setTitle(chatData.response.slug);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log("No more messages");
|
||||
setHasMoreMessages(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
return;
|
||||
});
|
||||
}, [props.conversationId]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.incomingMessages) {
|
||||
const lastMessage = props.incomingMessages[props.incomingMessages.length - 1];
|
||||
if (lastMessage && !lastMessage.completed) {
|
||||
setIncompleteIncomingMessageIndex(props.incomingMessages.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("isUserAtBottom", isUserAtBottom());
|
||||
|
||||
if (isUserAtBottom()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
}, [props.incomingMessages]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
// If the addedNodes property has one or more nodes
|
||||
for(let mutation of mutationsList) {
|
||||
for (let mutation of mutationsList) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
// Call your function here
|
||||
renderMathInElement(document.body, {
|
||||
@@ -88,9 +194,9 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
// if (isLoading) {
|
||||
// return <Loading />;
|
||||
// }
|
||||
|
||||
function constructAgentLink() {
|
||||
if (!data || !data.agent || !data.agent.slug) return `/agents`;
|
||||
@@ -111,9 +217,10 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
<ScrollArea className={`h-[80vh]`}>
|
||||
<div ref={ref}>
|
||||
<div className={styles.chatHistory} ref={chatHistoryRef}>
|
||||
<div ref={sentinelRef} style={{ height: '1px' }}></div>
|
||||
{(data && data.chat) && data.chat.map((chatMessage, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
key={`${index}fullHistory`}
|
||||
chatMessage={chatMessage}
|
||||
setReferencePanelData={setReferencePanelData}
|
||||
setShowReferencePanel={setShowReferencePanel}
|
||||
@@ -121,9 +228,77 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
))}
|
||||
{
|
||||
props.incomingMessages && props.incomingMessages.map((message, index) => {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
key={`${index}outgoing`}
|
||||
chatMessage={
|
||||
{
|
||||
message: message.rawQuery,
|
||||
context: [],
|
||||
onlineContext: {},
|
||||
created: message.timestamp,
|
||||
by: "you",
|
||||
intent: {},
|
||||
automationId: '',
|
||||
|
||||
}
|
||||
}
|
||||
setReferencePanelData={() => { }}
|
||||
setShowReferencePanel={() => { }}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500' />
|
||||
{
|
||||
message.trainOfThought && constructTrainOfThought(message.trainOfThought, index === incompleteIncomingMessageIndex, `${index}trainOfThought`)
|
||||
}
|
||||
<ChatMessage
|
||||
key={`${index}incoming`}
|
||||
chatMessage={
|
||||
{
|
||||
message: message.rawResponse,
|
||||
context: message.context,
|
||||
onlineContext: message.onlineContext,
|
||||
created: message.timestamp,
|
||||
by: "khoj",
|
||||
intent: {},
|
||||
automationId: '',
|
||||
}
|
||||
}
|
||||
setReferencePanelData={setReferencePanelData}
|
||||
setShowReferencePanel={setShowReferencePanel}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
props.pendingMessage &&
|
||||
<ChatMessage
|
||||
key={"pendingMessage"}
|
||||
chatMessage={
|
||||
{
|
||||
message: props.pendingMessage,
|
||||
context: [],
|
||||
onlineContext: {},
|
||||
created: new Date().toISOString(),
|
||||
by: "you",
|
||||
intent: {},
|
||||
automationId: '',
|
||||
}
|
||||
}
|
||||
setReferencePanelData={() => { }}
|
||||
setShowReferencePanel={() => { }}
|
||||
customClassName='fullHistory'
|
||||
borderLeftColor='orange-500'
|
||||
/>
|
||||
}
|
||||
{
|
||||
(hasValidReferences(referencePanelData) && showReferencePanel) &&
|
||||
<ReferencePanel referencePanelData={referencePanelData} setShowReferencePanel={setShowReferencePanel} />
|
||||
<ReferencePanel referencePanelData={referencePanelData} setShowReferencePanel={setShowReferencePanel} />
|
||||
}
|
||||
<div className={`${styles.agentIndicator}`}>
|
||||
<a className='no-underline mx-2 flex' href={constructAgentLink()} target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -110,6 +110,19 @@ button.copyButton img {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
div.trainOfThought strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
div.trainOfThought.primary strong {
|
||||
font-weight: 500;
|
||||
color: hsla(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
div.trainOfThought.primary p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
div.youfullHistory {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'highlight.js/styles/github.css'
|
||||
|
||||
import { hasValidReferences } from '../referencePanel/referencePanel';
|
||||
|
||||
import { ThumbsUp, ThumbsDown, Copy } from '@phosphor-icons/react';
|
||||
import { ThumbsUp, ThumbsDown, Copy, Brain, Cloud, Folder, Book } from '@phosphor-icons/react';
|
||||
|
||||
const md = new markdownIt({
|
||||
html: true,
|
||||
@@ -96,6 +96,19 @@ export interface SingleChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamMessage {
|
||||
rawResponse: string;
|
||||
trainOfThought: string[];
|
||||
context: Context[];
|
||||
onlineContext: {
|
||||
[key: string]: OnlineContextData
|
||||
}
|
||||
completed: boolean;
|
||||
rawQuery: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
export interface ChatHistoryData {
|
||||
chat: SingleChatMessage[];
|
||||
agent: AgentData;
|
||||
@@ -117,7 +130,7 @@ function FeedbackButtons() {
|
||||
}
|
||||
|
||||
function onClickMessage(event: React.MouseEvent<any>, chatMessage: SingleChatMessage, setReferencePanelData: Function, setShowReferencePanel: Function) {
|
||||
console.log("Clicked on message", chatMessage);
|
||||
// console.log("Clicked on message", chatMessage);
|
||||
setReferencePanelData(chatMessage);
|
||||
setShowReferencePanel(true);
|
||||
}
|
||||
@@ -130,6 +143,51 @@ interface ChatMessageProps {
|
||||
borderLeftColor?: string;
|
||||
}
|
||||
|
||||
interface TrainOfThoughtProps {
|
||||
message: string;
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
function chooseIconFromHeader(header: string, iconColor: string) {
|
||||
const compareHeader = header.toLowerCase();
|
||||
if (compareHeader.includes("understanding")) {
|
||||
return <Brain className={`inline mr-2 ${iconColor}`} />
|
||||
}
|
||||
|
||||
if (compareHeader.includes("generating")) {
|
||||
return <Cloud className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("data sources")) {
|
||||
return <Folder className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("notes")) {
|
||||
return <Folder className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
if (compareHeader.includes("read")) {
|
||||
return <Book className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
return <Brain className={`inline mr-2 ${iconColor}`} />;
|
||||
}
|
||||
|
||||
export function TrainOfThought(props: TrainOfThoughtProps) {
|
||||
// The train of thought comes in as a markdown-formatted string. It starts with a heading delimited by two asterisks at the start and end and a colon, followed by the message. Example: **header**: status. This function will parse the message and render it as a div.
|
||||
let extractedHeader = props.message.match(/\*\*(.*)\*\*/);
|
||||
let header = extractedHeader ? extractedHeader[1] : "";
|
||||
const iconColor = props.primary ? 'text-orange-400' : 'text-gray-500';
|
||||
const icon = chooseIconFromHeader(header, iconColor);
|
||||
let markdownRendered = md.render(props.message);
|
||||
return (
|
||||
<div className={`flex items-center ${props.primary ? 'text-gray-400' : 'text-gray-300'} ${styles.trainOfThought} ${props.primary ? styles.primary : ''}`} >
|
||||
{icon}
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownRendered }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatMessage(props: ChatMessageProps) {
|
||||
const [copySuccess, setCopySuccess] = useState<boolean>(false);
|
||||
|
||||
@@ -154,7 +212,6 @@ export default function ChatMessage(props: ChatMessageProps) {
|
||||
useEffect(() => {
|
||||
if (messageRef.current) {
|
||||
const preElements = messageRef.current.querySelectorAll('pre > .hljs');
|
||||
console.log("make copy button");
|
||||
preElements.forEach((preElement) => {
|
||||
const copyButton = document.createElement('button');
|
||||
const copyImage = document.createElement('img');
|
||||
|
||||
@@ -77,7 +77,6 @@ function renameConversation(conversationId: string, newTitle: string) {
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
@@ -96,7 +95,6 @@ function shareConversation(conversationId: string, setShareUrl: (url: string) =>
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
setShareUrl(data.url);
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -116,7 +114,6 @@ function deleteConversation(conversationId: string) {
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
@@ -153,7 +150,6 @@ function modifyFileFilterForConversation(
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
setAddedFiles(data);
|
||||
console.log(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
@@ -417,7 +413,6 @@ function ChatSessionActionMenu(props: ChatSessionActionMenuProps) {
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
console.log("shared");
|
||||
}}
|
||||
variant={'default'}>Copy</Button>
|
||||
}
|
||||
@@ -428,7 +423,6 @@ function ChatSessionActionMenu(props: ChatSessionActionMenuProps) {
|
||||
}
|
||||
|
||||
if (isDeleting) {
|
||||
console.log("Deleting");
|
||||
return (
|
||||
<AlertDialog
|
||||
open={isDeleting}
|
||||
|
||||
Reference in New Issue
Block a user