diff --git a/src/interface/web/app/agents/page.tsx b/src/interface/web/app/agents/page.tsx index 4ea1c7fe8..458031a15 100644 --- a/src/interface/web/app/agents/page.tsx +++ b/src/interface/web/app/agents/page.tsx @@ -96,7 +96,7 @@ function AgentModal(props: AgentModalProps) { props.setShowModal(false); }}> Close diff --git a/src/interface/web/app/chat/chat.module.css b/src/interface/web/app/chat/chat.module.css index 56b15d30e..b9bd68c76 100644 --- a/src/interface/web/app/chat/chat.module.css +++ b/src/interface/web/app/chat/chat.module.css @@ -1,6 +1,6 @@ div.main { height: 100vh; - color: black; + color: hsla(var(--foreground)); } .suggestions { @@ -11,26 +11,29 @@ div.main { } div.inputBox { - display: grid; - grid-template-columns: 1fr auto; - padding: 1rem; - border-radius: 1rem; - background-color: #f5f5f5; - box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 4px 10px var(--box-shadow-color); + margin-bottom: 20px; + gap: 12px; + padding-left: 20px; + padding-right: 20px; + align-content: center; } input.inputBox { border: none; - outline: none; - background-color: transparent; } input.inputBox:focus { - border: none; outline: none; background-color: transparent; } +div.inputBox:focus { + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); +} + div.chatBodyFull { display: grid; grid-template-columns: 1fr; @@ -54,7 +57,7 @@ div.chatBody { } .inputBox { - color: black; + color: hsla(var(--foreground)); } div.chatLayout { @@ -65,9 +68,7 @@ div.chatLayout { div.chatBox { display: grid; - gap: 1rem; height: 100%; - padding: 1rem; } div.titleBar { @@ -75,6 +76,25 @@ div.titleBar { grid-template-columns: 1fr auto; } +div.chatBoxBody { + display: grid; + height: 100%; + width: 70%; + margin: auto; +} + +div.agentIndicator a { + display: flex; + text-align: center; + align-content: center; + align-items: center; +} + +div.agentIndicator { + padding: 10px; +} + + @media (max-width: 768px) { div.chatBody { grid-template-columns: 0fr 1fr; @@ -84,3 +104,23 @@ div.titleBar { padding: 0; } } + +@media screen and (max-width: 768px) { + div.inputBox { + margin-bottom: 0px; + } + + div.chatBoxBody { + width: 100%; + } + + div.chatBox { + padding: 0; + } + + div.chatLayout { + gap: 0; + grid-template-columns: 1fr; + } + +} diff --git a/src/interface/web/app/chat/layout.tsx b/src/interface/web/app/chat/layout.tsx index 46161774c..1da0e7a7a 100644 --- a/src/interface/web/app/chat/layout.tsx +++ b/src/interface/web/app/chat/layout.tsx @@ -16,6 +16,15 @@ export default function RootLayout({ }>) { return ( + {children} diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx index ab7b16d72..ac641d6a5 100644 --- a/src/interface/web/app/chat/page.tsx +++ b/src/interface/web/app/chat/page.tsx @@ -6,73 +6,193 @@ import React, { Suspense, useEffect, useState } from 'react'; import SuggestionCard from '../components/suggestions/suggestionCard'; import SidePanel from '../components/sidePanel/chatHistorySidePanel'; import ChatHistory from '../components/chatHistory/chatHistory'; -import { SingleChatMessage } from '../components/chatMessage/chatMessage'; import NavMenu from '../components/navMenu/navMenu'; import { useSearchParams } from 'next/navigation' -import ReferencePanel, { hasValidReferences } from '../components/referencePanel/referencePanel'; +import Loading from '../components/loading/loading'; + +import { handleCompiledReferences, handleImageResponse, setupWebSocket } from '../common/chatFunctions'; import 'katex/dist/katex.min.css'; -interface ChatOptions { - [key: string]: string -} +import { StreamMessage } from '../components/chatMessage/chatMessage'; +import { welcomeConsole } from '../common/utils'; +import ChatInputArea, { ChatOptions } from '../components/chatInputArea/chatInputArea'; +import { useAuthenticatedData } from '../common/auth'; + + const styleClassOptions = ['pink', 'blue', 'green', 'yellow', 'purple']; +interface ChatBodyDataProps { + chatOptionsData: ChatOptions | null; + setTitle: (title: string) => void; + onConversationIdChange?: (conversationId: string) => void; + setQueryToProcess: (query: string) => void; + streamedMessages: StreamMessage[]; + setUploadedFiles: (files: string[]) => void; + isMobileWidth?: boolean; + isLoggedIn: boolean; +} + + +function ChatBodyData(props: ChatBodyDataProps) { + const searchParams = useSearchParams(); + const conversationId = searchParams.get('conversationId'); + const [message, setMessage] = useState(''); + const [processingMessage, setProcessingMessage] = useState(false); -function ChatBodyData({ chatOptionsData }: { chatOptionsData: ChatOptions | null }) { - const searchParams = useSearchParams(); - const conversationId = searchParams.get('conversationId'); - const [showReferencePanel, setShowReferencePanel] = useState(true); - const [referencePanelData, setReferencePanelData] = useState(null); + useEffect(() => { + if (conversationId) { + props.onConversationIdChange?.(conversationId); + } + }, [conversationId, props.onConversationIdChange]); + + useEffect(() => { + if (message) { + setProcessingMessage(true); + props.setQueryToProcess(message); + } + }, [message]); + + useEffect(() => { + if (props.streamedMessages && + props.streamedMessages.length > 0 && + props.streamedMessages[props.streamedMessages.length - 1].completed) { + + setProcessingMessage(false); + } else { + setMessage(''); + } + }, [props.streamedMessages]); if (!conversationId) { return (
- {chatOptionsData && Object.entries(chatOptionsData).map(([key, value]) => ( + {props.chatOptionsData && Object.entries(props.chatOptionsData).map(([key, value]) => ( + /> ))}
); } - return( -
- - { - (hasValidReferences(referencePanelData) && showReferencePanel) && - - } -
- ); -} - -function Loading() { - return

🌀 Loading...

; -} - -function handleChatInput(e: React.FormEvent) { - const target = e.target as HTMLInputElement; - console.log(target.value); + return ( + <> +
+ +
+
+ setMessage(message)} + sendDisabled={processingMessage} + chatOptionsData={props.chatOptionsData} + conversationId={conversationId} + isMobileWidth={props.isMobileWidth} + setUploadedFiles={props.setUploadedFiles} /> +
+ + ); } export default function Chat() { const [chatOptionsData, setChatOptionsData] = useState(null); - const [isLoading, setLoading] = useState(true) + const [isLoading, setLoading] = useState(true); + const [title, setTitle] = useState('Khoj AI - Chat'); + const [conversationId, setConversationID] = useState(null); + const [chatWS, setChatWS] = useState(null); + const [messages, setMessages] = useState([]); + const [queryToProcess, setQueryToProcess] = useState(''); + const [processQuerySignal, setProcessQuerySignal] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isMobileWidth, setIsMobileWidth] = useState(false); + + const authenticatedData = useAuthenticatedData(); + + welcomeConsole(); + + + const handleWebSocketMessage = (event: MessageEvent) => { + let chunk = event.data; + + let currentMessage = messages.find(message => !message.completed); + + if (!currentMessage) { + console.error("No current message found"); + return; + } - useEffect(() => { + // Process WebSocket streamed data + if (chunk === "start_llm_response") { + console.log("Started streaming", new Date()); + } else if (chunk === "end_llm_response") { + currentMessage.completed = true; + } else { + // Get the current message + // Process and update state with the new message + if (chunk.includes("application/json")) { + chunk = JSON.parse(chunk); + } + + const contentType = chunk["content-type"]; + if (contentType === "application/json") { + try { + if (chunk.image || chunk.detail) { + let responseWithReference = handleImageResponse(chunk); + console.log("Image response", responseWithReference); + if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response; + if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online; + if (responseWithReference.context) currentMessage.context = responseWithReference.context; + } else if (chunk.type == "status") { + currentMessage.trainOfThought.push(chunk.message); + } else if (chunk.type == "rate_limit") { + console.log("Rate limit message", chunk); + currentMessage.rawResponse = chunk.message; + } else { + console.log("any message", chunk); + } + } catch (error) { + console.error("Error processing message", error); + currentMessage.completed = true; + } finally { + // no-op + } + + } else { + // Update the current message with the new chunk + if (chunk && chunk.includes("### compiled references:")) { + let responseWithReference = handleCompiledReferences(chunk, ""); + currentMessage.rawResponse += responseWithReference.response; + + if (responseWithReference.response) currentMessage.rawResponse = responseWithReference.response; + if (responseWithReference.online) currentMessage.onlineContext = responseWithReference.online; + if (responseWithReference.context) currentMessage.context = responseWithReference.context; + } else { + // If the chunk is not a JSON object, just display it as is + currentMessage.rawResponse += chunk; + } + + } + }; + // Update the state with the new message, currentMessage + setMessages([...messages]); + } + + useEffect(() => { fetch('/api/chat/options') .then(response => response.json()) .then((data: ChatOptions) => { setLoading(false); // Render chat options, if any if (data) { - console.log(data); setChatOptionsData(data); } }) @@ -80,28 +200,92 @@ export default function Chat() { console.error(err); return; }); - }, []); + + setIsMobileWidth(window.innerWidth < 786); + + window.addEventListener('resize', () => { + setIsMobileWidth(window.innerWidth < 786); + }); + + }, []); + + useEffect(() => { + if (chatWS && queryToProcess) { + // Add a new object to the state + const newStreamMessage: StreamMessage = { + rawResponse: "", + trainOfThought: [], + context: [], + onlineContext: {}, + completed: false, + timestamp: (new Date()).toISOString(), + rawQuery: queryToProcess || "", + } + setMessages(prevMessages => [...prevMessages, newStreamMessage]); + setProcessQuerySignal(true); + } else { + if (!chatWS) { + console.error("No WebSocket connection available"); + } + if (!queryToProcess) { + console.error("No query to process"); + } + } + }, [queryToProcess]); + + useEffect(() => { + if (processQuerySignal && chatWS) { + setProcessQuerySignal(false); + chatWS.onmessage = handleWebSocketMessage; + chatWS?.send(queryToProcess); + } + }, [processQuerySignal]); + + useEffect(() => { + (async () => { + if (conversationId) { + const newWS = await setupWebSocket(conversationId); + setChatWS(newWS); + } + })(); + }, [conversationId]); + + const handleConversationIdChange = (newConversationId: string) => { + setConversationID(newConversationId); + }; + + + if (isLoading) { + return ; + } return (
+ + {title} +
- +
- - Khoj AI - Chat - - -
+ +
}> - +
-
- handleChatInput(e)} /> - -
) diff --git a/src/interface/web/app/common/chatFunctions.ts b/src/interface/web/app/common/chatFunctions.ts index e69de29bb..480f67462 100644 --- a/src/interface/web/app/common/chatFunctions.ts +++ b/src/interface/web/app/common/chatFunctions.ts @@ -0,0 +1,299 @@ +import { Context, OnlineContextData } from "../components/chatMessage/chatMessage"; + +interface ResponseWithReferences { + context?: Context[]; + online?: { + [key: string]: OnlineContextData + } + response?: string; +} + +export function handleCompiledReferences(chunk: string, currentResponse: string) { + const rawReference = chunk.split("### compiled references:")[1]; + const rawResponse = chunk.split("### compiled references:")[0]; + let references: ResponseWithReferences = {}; + + // Set the initial response + references.response = currentResponse + rawResponse; + + const rawReferenceAsJson = JSON.parse(rawReference); + if (rawReferenceAsJson instanceof Array) { + references.context = rawReferenceAsJson; + } else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) { + references.online = rawReferenceAsJson; + } + + return references; +} + +async function sendChatStream( + message: string, + conversationId: string, + setIsLoading: (loading: boolean) => void, + setInitialResponse: (response: string) => void, + setInitialReferences: (references: ResponseWithReferences) => void) { + setIsLoading(true); + // Send a message to the chat server to verify the fact + const chatURL = "/api/chat"; + const apiURL = `${chatURL}?q=${encodeURIComponent(message)}&client=web&stream=true&conversation_id=${conversationId}`; + try { + const response = await fetch(apiURL); + if (!response.body) throw new Error("No response body found"); + + const reader = response.body?.getReader(); + let decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + let chunk = decoder.decode(value, { stream: true }); + + if (chunk.includes("### compiled references:")) { + const references = handleCompiledReferences(chunk, result); + if (references.response) { + result = references.response; + setInitialResponse(references.response); + setInitialReferences(references); + } + } else { + result += chunk; + setInitialResponse(result); + } + } + } catch (error) { + console.error("Error verifying statement: ", error); + } finally { + setIsLoading(false); + } +} + +export const setupWebSocket = async (conversationId: string, initialMessage?: string) => { + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + + const host = process.env.NODE_ENV === 'production' ? window.location.host : 'localhost:42110'; + + let webSocketUrl = `${wsProtocol}//${host}/api/chat/ws`; + + if (conversationId === null) return null; + + if (conversationId) { + webSocketUrl += `?conversation_id=${conversationId}`; + } + + const chatWS = new WebSocket(webSocketUrl); + + chatWS.onopen = () => { + console.log('WebSocket connection established'); + if (initialMessage) { + chatWS.send(initialMessage); + } + }; + + chatWS.onmessage = (event) => { + console.log(event.data); + }; + + chatWS.onerror = (error) => { + console.error('WebSocket error: ', error); + }; + + chatWS.onclose = () => { + console.log('WebSocket connection closed'); + }; + + return chatWS; +}; + +export function handleImageResponse(imageJson: any) { + + let rawResponse = ""; + + if (imageJson.image) { + const inferredQuery = imageJson.inferredQueries?.[0] ?? "generated image"; + + // If response has image field, response is a generated image. + if (imageJson.intentType === "text-to-image") { + rawResponse += `![generated_image](data:image/png;base64,${imageJson.image})`; + } else if (imageJson.intentType === "text-to-image2") { + rawResponse += `![generated_image](${imageJson.image})`; + } else if (imageJson.intentType === "text-to-image-v3") { + rawResponse = `![](data:image/webp;base64,${imageJson.image})`; + } + if (inferredQuery) { + rawResponse += `\n\n**Inferred Query**:\n\n${inferredQuery}`; + } + } + + let reference: ResponseWithReferences = {}; + + if (imageJson.context && imageJson.context.length > 0) { + const rawReferenceAsJson = imageJson.context; + if (rawReferenceAsJson instanceof Array) { + reference.context = rawReferenceAsJson; + } else if (typeof rawReferenceAsJson === "object" && rawReferenceAsJson !== null) { + reference.online = rawReferenceAsJson; + } + } + if (imageJson.detail) { + // The detail field contains the improved image prompt + rawResponse += imageJson.detail; + } + + reference.response = rawResponse; + return reference; +} + + +export function modifyFileFilterForConversation( + conversationId: string | null, + filenames: string[], + setAddedFiles: (files: string[]) => void, + mode: 'add' | 'remove') { + + if (!conversationId) { + console.error("No conversation ID provided"); + return; + } + + const method = mode === 'add' ? 'POST' : 'DELETE'; + + const body = { + conversation_id: conversationId, + filenames: filenames, + } + const addUrl = `/api/chat/conversation/file-filters/bulk`; + + fetch(addUrl, { + method: method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + .then(response => response.json()) + .then(data => { + console.log("ADDEDFILES DATA: ", data); + setAddedFiles(data); + }) + .catch(err => { + console.error(err); + return; + }); +} + +export function uploadDataForIndexing( + files: FileList, + setWarning: (warning: string) => void, + setUploading: (uploading: boolean) => void, + setError: (error: string) => void, + setUploadedFiles?: (files: string[]) => void, + conversationId?: string | null) { + + const allowedExtensions = ['text/org', 'text/markdown', 'text/plain', 'text/html', 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + const allowedFileEndings = ['org', 'md', 'txt', 'html', 'pdf', 'docx']; + const badFiles: string[] = []; + const goodFiles: File[] = []; + + const uploadedFiles: string[] = []; + + for (let file of files) { + const fileEnding = file.name.split('.').pop(); + if (!file || !file.name || !fileEnding) { + if (file) { + badFiles.push(file.name); + } + } else if ((!allowedExtensions.includes(file.type) && !allowedFileEndings.includes(fileEnding.toLowerCase()))) { + badFiles.push(file.name); + } else { + goodFiles.push(file); + } + } + + if (goodFiles.length === 0) { + setWarning("No supported files found"); + return; + } + + if (badFiles.length > 0) { + setWarning("The following files are not supported yet:\n" + badFiles.join('\n')); + } + + + const formData = new FormData(); + + // Create an array of Promises for file reading + const fileReadPromises = Array.from(goodFiles).map(file => { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = function (event) { + + if (event.target === null) { + reject(); + return; + } + + let fileContents = event.target.result; + let fileType = file.type; + let fileName = file.name; + if (fileType === "") { + let fileExtension = fileName.split('.').pop(); + if (fileExtension === "org") { + fileType = "text/org"; + } else if (fileExtension === "md") { + fileType = "text/markdown"; + } else if (fileExtension === "txt") { + fileType = "text/plain"; + } else if (fileExtension === "html") { + fileType = "text/html"; + } else if (fileExtension === "pdf") { + fileType = "application/pdf"; + } else { + // Skip this file if its type is not supported + resolve(); + return; + } + } + + if (fileContents === null) { + reject(); + return; + } + + let fileObj = new Blob([fileContents], { type: fileType }); + formData.append("files", fileObj, file.name); + resolve(); + }; + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); + }); + + setUploading(true); + + // Wait for all files to be read before making the fetch request + Promise.all(fileReadPromises) + .then(() => { + return fetch("/api/v1/index/update?force=false&client=web", { + method: "POST", + body: formData, + }); + }) + .then((data) => { + for (let file of goodFiles) { + uploadedFiles.push(file.name); + if (conversationId && setUploadedFiles) { + modifyFileFilterForConversation(conversationId, [file.name], setUploadedFiles, 'add'); + } + } + if (setUploadedFiles) setUploadedFiles(uploadedFiles); + }) + .catch((error) => { + console.log(error); + setError(`Error uploading file: ${error}`); + }) + .finally(() => { + setUploading(false); + }); +} diff --git a/src/interface/web/app/common/utils.ts b/src/interface/web/app/common/utils.ts new file mode 100644 index 000000000..5cc9d767e --- /dev/null +++ b/src/interface/web/app/common/utils.ts @@ -0,0 +1,17 @@ +export function welcomeConsole() { + console.log(`%c %s`, "font-family:monospace", ` + __ __ __ __ ______ __ _____ __ + /\\ \\/ / /\\ \\_\\ \\ /\\ __ \\ /\\ \\ /\\ __ \\ /\\ \\ + \\ \\ _"-. \\ \\ __ \\ \\ \\ \\/\\ \\ _\\_\\ \\ \\ \\ __ \\ \\ \\ \\ + \\ \\_\\ \\_\\ \\ \\_\\ \\_\\ \\ \\_____\\ /\\_____\\ \\ \\_\\ \\_\\ \\ \\_\\ + \\/_/\\/_/ \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_/ + + + Greetings traveller, + + I am ✨Khoj✨, your open-source, personal AI copilot. + + See my source code at https://github.com/khoj-ai/khoj + Read my operating manual at https://docs.khoj.dev + `); +} diff --git a/src/interface/web/app/components/chatHistory/chatHistory.module.css b/src/interface/web/app/components/chatHistory/chatHistory.module.css index 4597ae77d..984fb9223 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.module.css +++ b/src/interface/web/app/components/chatHistory/chatHistory.module.css @@ -7,6 +7,24 @@ div.chatHistory { div.chatLayout { height: 80vh; overflow-y: auto; - /* width: 80%; */ margin: 0 auto; } + +div.agentIndicator a { + display: flex; + text-align: center; + align-content: center; + align-items: center; +} + +div.agentIndicator { + padding: 10px; +} + +div.trainOfThought { + border: 1px var(--border-color) solid; + border-radius: 16px; + padding: 16px; + margin: 12px; + box-shadow: 0 4px 10px var(--box-shadow-color); +} diff --git a/src/interface/web/app/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx index 2fd9cfaa4..8d02c0862 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.tsx +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -3,11 +3,16 @@ import styles from './chatHistory.module.css'; import { useRef, useEffect, useState } from 'react'; -import ChatMessage, { ChatHistoryData, SingleChatMessage } from '../chatMessage/chatMessage'; +import ChatMessage, { ChatHistoryData, StreamMessage, TrainOfThought } from '../chatMessage/chatMessage'; + +import { ScrollArea } from "@/components/ui/scroll-area" import renderMathInElement from 'katex/contrib/auto-render'; import 'katex/dist/katex.min.css'; -import 'highlight.js/styles/github.css' + +import Loading, { InlineLoading } from '../loading/loading'; + +import { Lightbulb } from "@phosphor-icons/react"; interface ChatResponse { status: string; @@ -20,42 +25,117 @@ interface ChatHistory { interface ChatHistoryProps { conversationId: string; - setReferencePanelData: Function; - setShowReferencePanel: Function; + setTitle: (title: string) => void; + incomingMessages?: StreamMessage[]; + pendingMessage?: string; + publicConversationSlug?: string; +} + + +function constructTrainOfThought(trainOfThought: string[], lastMessage: boolean, 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 [isLoading, setLoading] = useState(true) + const [currentPage, setCurrentPage] = useState(0); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + const ref = useRef(null); - const chatHistoryRef = useRef(null); + const chatHistoryRef = useRef(null); + const sentinelRef = useRef(null); + const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState(null); + const [fetchingData, setFetchingData] = useState(false); + const [isMobileWidth, setIsMobileWidth] = useState(false); - useEffect(() => { - fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=10`) - .then(response => response.json()) - .then((chatData: ChatResponse) => { - setLoading(false); - // Render chat options, if any - if (chatData) { - console.log(chatData); - setData(chatData.response); - } - }) - .catch(err => { - console.error(err); - return; - }); - }, [props.conversationId]); + 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(); + } + + }, [chatHistoryRef.current, data]); + + 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(); + }, [sentinelRef.current, hasMoreMessages, currentPage, fetchingData]); + + useEffect(() => { + setHasMoreMessages(true); + setFetchingData(false); + setCurrentPage(0); + setData(null); + }, [props.conversationId]); + useEffect(() => { + console.log(props.incomingMessages); + 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]); useEffect(() => { const observer = new MutationObserver((mutationsList, observer) => { // If the addedNodes property has one or more nodes - for(let mutation of mutationsList) { - if(mutation.type === 'childList' && mutation.addedNodes.length > 0) { + for (let mutation of mutationsList) { + if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // Call your function here renderMathInElement(document.body, { delimiters: [ @@ -77,24 +157,175 @@ export default function ChatHistory(props: ChatHistoryProps) { return () => observer.disconnect(); }, []); - if (isLoading) { - return

🌀 Loading...

; + const 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.length > 0) { + + if (chatData.response.chat.length === data?.chat.length) { + setHasMoreMessages(false); + setFetchingData(false); + return; + } + + setData(chatData.response); + + if (currentPage < 2) { + scrollToBottom(); + } + setFetchingData(false); + } else { + setHasMoreMessages(false); + } + }) + .catch(err => { + console.error(err); + }); + }; + + 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 constructAgentAvatar() { + if (!data || !data.agent || !data.agent.avatar) return `/avatar.png`; + return data.agent.avatar; + } + + function constructAgentName() { + if (!data || !data.agent || !data.agent.name) return `Agent`; + return data.agent.name; + } + + + 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, + `${index}trainOfThought`, message.completed) + } + + + ) + }) + } + { + props.pendingMessage && + + } +
-
+ ) } diff --git a/src/interface/web/app/components/chatInputArea/chatInputArea.module.css b/src/interface/web/app/components/chatInputArea/chatInputArea.module.css new file mode 100644 index 000000000..cfee75f16 --- /dev/null +++ b/src/interface/web/app/components/chatInputArea/chatInputArea.module.css @@ -0,0 +1,4 @@ +div.actualInputArea { + display: grid; + grid-template-columns: auto 1fr auto auto; +} diff --git a/src/interface/web/app/components/chatInputArea/chatInputArea.tsx b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx new file mode 100644 index 000000000..760301d93 --- /dev/null +++ b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx @@ -0,0 +1,318 @@ + +import styles from './chatInputArea.module.css'; +import React, { useEffect, useRef, useState } from 'react'; + +import { uploadDataForIndexing } from '../../common/chatFunctions'; +import { Progress } from "@/components/ui/progress" + +import 'katex/dist/katex.min.css'; +import { + ArrowCircleUp, + ArrowRight, + Browser, + ChatsTeardrop, + FileArrowUp, + GlobeSimple, + Gps, + Image, + Microphone, + Notebook, + Question, + Robot, + Shapes +} from '@phosphor-icons/react'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" + +import { Textarea } from "@/components/ui/textarea" +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { Popover, PopoverContent } from '@/components/ui/popover'; +import { PopoverTrigger } from '@radix-ui/react-popover'; +import Link from 'next/link'; +import { AlertDialogCancel } from '@radix-ui/react-alert-dialog'; +import LoginPrompt from '../loginPrompt/loginPrompt'; + +export interface ChatOptions { + [key: string]: string +} + +interface ChatInputProps { + sendMessage: (message: string) => void; + sendDisabled: boolean; + setUploadedFiles?: (files: string[]) => void; + conversationId?: string | null; + chatOptionsData?: ChatOptions | null; + isMobileWidth?: boolean; + isLoggedIn: boolean; +} + +export default function ChatInputArea(props: ChatInputProps) { + const [message, setMessage] = useState(''); + const fileInputRef = useRef(null); + + const [warning, setWarning] = useState(null); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const [loginRedirectMessage, setLoginRedirectMessage] = useState(null); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + + const [progressValue, setProgressValue] = useState(0); + + useEffect(() => { + if (!uploading) { + setProgressValue(0); + } + + if (uploading) { + const interval = setInterval(() => { + setProgressValue((prev) => { + const increment = Math.floor(Math.random() * 5) + 1; // Generates a random number between 1 and 5 + const nextValue = prev + increment; + return nextValue < 100 ? nextValue : 100; // Ensures progress does not exceed 100 + }); + }, 800); + return () => clearInterval(interval); + } + }, [uploading]); + + function onSendMessage() { + if (!message.trim()) return; + + if (!props.isLoggedIn) { + setLoginRedirectMessage('Hey there, you need to be signed in to send messages to Khoj AI'); + setShowLoginPrompt(true); + return; + } + + props.sendMessage(message.trim()); + setMessage(''); + } + + function handleSlashCommandClick(command: string) { + setMessage(`/${command} `); + } + + function handleFileButtonClick() { + if (!fileInputRef.current) return; + fileInputRef.current.click(); + } + + function handleFileChange(event: React.ChangeEvent) { + if (!event.target.files) return; + + if (!props.isLoggedIn) { + setLoginRedirectMessage('Whoa! You need to login to upload files'); + setShowLoginPrompt(true); + return; + } + + uploadDataForIndexing( + event.target.files, + setWarning, + setUploading, + setError, + props.setUploadedFiles, + props.conversationId); + } + + function getIconForSlashCommand(command: string) { + if (command.includes('summarize')) { + return + } + + if (command.includes('help')) { + return + } + + if (command.includes('automation')) { + return + } + + if (command.includes('webpage')) { + return + } + + if (command.includes('notes')) { + return + } + + if (command.includes('image')) { + return + } + + if (command.includes('default')) { + return + } + + if (command.includes('general')) { + return + } + + if (command.includes('online')) { + return + } + return + } + + return ( + <> + { + showLoginPrompt && loginRedirectMessage && ( + + ) + } + { + uploading && ( + + + + Uploading data. Please wait. + + + + + setUploading(false)}>Dismiss + + + )} + { + warning && ( + + + + Data Upload Warning + + {warning} + setWarning(null)}>Close + + + ) + } + { + error && ( + + + + Oh no! + Something went wrong while uploading your data + + {error} + setError(null)}>Close + + + ) + } + { + (message.startsWith('/') && message.split(' ').length === 1) && +
+ + + + + e.preventDefault()} + className={`${props.isMobileWidth ? 'w-[100vw]' : 'w-full'} rounded-md`}> + + + + No matching commands. + + {props.chatOptionsData && Object.entries(props.chatOptionsData).map(([key, value]) => ( + handleSlashCommandClick(key)}> +
+
+ {getIconForSlashCommand(key)} + /{key} +
+
+ {value} +
+
+
+ ))} +
+ +
+
+
+
+
+ } +
+ + + +
+