References, mobile friendly chat sessions and file filter

This commit is contained in:
sabaimran
2024-07-07 15:42:29 +05:30
parent aec44a0b89
commit 6f8a65c529
20 changed files with 1732 additions and 382 deletions

View File

@@ -10,7 +10,8 @@ import NavMenu from '../components/navMenu/navMenu';
import { useSearchParams } from 'next/navigation'
import Loading from '../components/loading/loading';
import { handleCompiledReferences, handleImageResponse, setupWebSocket } from '../common/chatFunctions';
import { handleCompiledReferences, handleImageResponse, setupWebSocket, uploadDataForIndexing } from '../common/chatFunctions';
import { Progress } from "@/components/ui/progress"
import 'katex/dist/katex.min.css';
import { Lightbulb, ArrowCircleUp, FileArrowUp, Microphone } from '@phosphor-icons/react';
@@ -18,15 +19,25 @@ import { Lightbulb, ArrowCircleUp, FileArrowUp, Microphone } from '@phosphor-ico
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Button } from '@/components/ui/button';
import { Context, OnlineContextData, StreamMessage } from '../components/chatMessage/chatMessage';
import { StreamMessage } from '../components/chatMessage/chatMessage';
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
interface ChatInputProps {
sendMessage: (message: string) => void;
sendDisabled: boolean;
setUploadedFiles?: (files: string[]) => void;
conversationId?: string | null;
}
function ChatInputArea(props: ChatInputProps) {
const [message, setMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progressValue, setProgressValue] = useState(0);
useEffect(() => {
if (message.startsWith('/')) {
@@ -35,17 +46,106 @@ function ChatInputArea(props: ChatInputProps) {
}
}, [message]);
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() {
props.sendMessage(message);
setMessage('');
}
function handleFileButtonClick() {
if (!fileInputRef.current) return;
fileInputRef.current.click();
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (!event.target.files) return;
uploadDataForIndexing(
event.target.files,
setWarning,
setUploading,
setError,
props.setUploadedFiles,
props.conversationId);
}
return (
<>
{
uploading && (
<AlertDialog
open={uploading}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Uploading data. Please wait.</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Progress
indicatorColor='bg-slate-500'
className='w-full h-2 rounded-full'
value={progressValue} />
</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setUploading(false)}>Dismiss</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)}
{
warning && (
<AlertDialog
open={warning !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data Upload Warning</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>{warning}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setWarning(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
{
error && (
<AlertDialog
open={error !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Oh no!</AlertDialogTitle>
<AlertDialogDescription>Something went wrong while uploading your data</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogDescription>{error}</AlertDialogDescription>
<AlertDialogAction className='bg-slate-400 hover:bg-slate-500' onClick={() => setError(null)}>Close</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
<input
type="file"
multiple={true}
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Button
variant={'ghost'}
className="!bg-none p-1 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}>
disabled={props.sendDisabled}
onClick={handleFileButtonClick}>
<FileArrowUp weight='fill' />
</Button>
<div className="grid w-full gap-1.5">
@@ -91,6 +191,7 @@ interface ChatBodyDataProps {
onConversationIdChange?: (conversationId: string) => void;
setQueryToProcess: (query: string) => void;
streamedMessages: StreamMessage[];
setUploadedFiles: (files: string[]) => void;
}
@@ -153,10 +254,18 @@ function ChatBodyData(props: ChatBodyDataProps) {
return (
<>
<div className={false ? styles.chatBody : styles.chatBodyFull}>
<ChatHistory conversationId={conversationId} setTitle={props.setTitle} pendingMessage={processingMessage ? message : ''} incomingMessages={props.streamedMessages} />
<ChatHistory
conversationId={conversationId}
setTitle={props.setTitle}
pendingMessage={processingMessage ? message : ''}
incomingMessages={props.streamedMessages} />
</div>
<div className={`${styles.inputBox} bg-background align-middle items-center justify-center`}>
<ChatInputArea sendMessage={(message) => setMessage(message)} sendDisabled={processingMessage} />
<ChatInputArea
sendMessage={(message) => setMessage(message)}
sendDisabled={processingMessage}
conversationId={conversationId}
setUploadedFiles={props.setUploadedFiles} />
</div>
</>
);
@@ -171,6 +280,7 @@ export default function Chat() {
const [messages, setMessages] = useState<StreamMessage[]>([]);
const [queryToProcess, setQueryToProcess] = useState<string>('');
const [processQuerySignal, setProcessQuerySignal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const handleWebSocketMessage = (event: MessageEvent) => {
@@ -318,7 +428,7 @@ export default function Chat() {
{title}
</title>
<div className={styles.sidePanel}>
<SidePanel webSocketConnected={chatWS !== null} conversationId={conversationId} />
<SidePanel webSocketConnected={chatWS !== null} conversationId={conversationId} uploadedFiles={uploadedFiles} />
</div>
<div className={styles.chatBox}>
<NavMenu selected="Chat" title={title} />
@@ -329,6 +439,7 @@ export default function Chat() {
chatOptionsData={chatOptionsData}
setTitle={setTitle}
setQueryToProcess={setQueryToProcess}
setUploadedFiles={setUploadedFiles}
onConversationIdChange={handleConversationIdChange} />
</Suspense>
</div>

View File

@@ -32,41 +32,41 @@ async function sendChatStream(
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");
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 = "";
const reader = response.body?.getReader();
let decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
while (true) {
const { done, value } = await reader.read();
if (done) break;
let chunk = decoder.decode(value, { stream: true });
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);
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);
}
} catch (error) {
console.error("Error verifying statement: ", error);
} finally {
setIsLoading(false);
}
}
export const setupWebSocket = async (conversationId: string) => {
@@ -143,3 +143,156 @@ export function handleImageResponse(imageJson: any) {
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<void>((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);
});
}

View File

@@ -3,9 +3,7 @@
import styles from './chatHistory.module.css';
import { useRef, useEffect, useState } from 'react';
import ChatMessage, { ChatHistoryData, SingleChatMessage, StreamMessage, TrainOfThought } from '../chatMessage/chatMessage';
import ReferencePanel, { hasValidReferences } from '../referencePanel/referencePanel';
import ChatMessage, { ChatHistoryData, StreamMessage, TrainOfThought } from '../chatMessage/chatMessage';
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -61,10 +59,19 @@ export default function ChatHistory(props: ChatHistoryProps) {
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);
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.
@@ -116,7 +123,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
fetch(`/api/chat/history?client=web&conversation_id=${props.conversationId}&n=${10 * nextPage}`)
.then(response => response.json())
.then((chatData: ChatResponse) => {
console.log(chatData);
props.setTitle(chatData.response.slug);
if (chatData && chatData.response && chatData.response.chat.length > 0) {
if (chatData.response.chat.length === data?.chat.length) {
@@ -229,9 +236,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
{(data && data.chat) && data.chat.map((chatMessage, index) => (
<ChatMessage
key={`${index}fullHistory`}
isMobileWidth={isMobileWidth}
chatMessage={chatMessage}
setReferencePanelData={setReferencePanelData}
setShowReferencePanel={setShowReferencePanel}
customClassName='fullHistory'
borderLeftColor='orange-500'
/>
@@ -242,6 +248,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
<>
<ChatMessage
key={`${index}outgoing`}
isMobileWidth={isMobileWidth}
chatMessage={
{
message: message.rawQuery,
@@ -249,20 +256,21 @@ export default function ChatHistory(props: ChatHistoryProps) {
onlineContext: {},
created: message.timestamp,
by: "you",
intent: {},
automationId: '',
}
}
setReferencePanelData={() => { }}
setShowReferencePanel={() => { }}
customClassName='fullHistory'
borderLeftColor='orange-500' />
{
message.trainOfThought && constructTrainOfThought(message.trainOfThought, index === incompleteIncomingMessageIndex, `${index}trainOfThought`, message.completed)
message.trainOfThought &&
constructTrainOfThought(
message.trainOfThought,
index === incompleteIncomingMessageIndex,
`${index}trainOfThought`, message.completed)
}
<ChatMessage
key={`${index}incoming`}
isMobileWidth={isMobileWidth}
chatMessage={
{
message: message.rawResponse,
@@ -270,12 +278,10 @@ export default function ChatHistory(props: ChatHistoryProps) {
onlineContext: message.onlineContext,
created: message.timestamp,
by: "khoj",
intent: {},
automationId: '',
rawQuery: message.rawQuery,
}
}
setReferencePanelData={setReferencePanelData}
setShowReferencePanel={setShowReferencePanel}
customClassName='fullHistory'
borderLeftColor='orange-500'
/>
@@ -287,6 +293,7 @@ export default function ChatHistory(props: ChatHistoryProps) {
props.pendingMessage &&
<ChatMessage
key={"pendingMessage"}
isMobileWidth={isMobileWidth}
chatMessage={
{
message: props.pendingMessage,
@@ -294,20 +301,13 @@ export default function ChatHistory(props: ChatHistoryProps) {
onlineContext: {},
created: new Date().toISOString(),
by: "you",
intent: {},
automationId: '',
}
}
setReferencePanelData={() => { }}
setShowReferencePanel={() => { }}
customClassName='fullHistory'
borderLeftColor='orange-500'
/>
}
{
(hasValidReferences(referencePanelData) && showReferencePanel) &&
<ReferencePanel referencePanelData={referencePanelData} setShowReferencePanel={setShowReferencePanel} />
}
<div className={`${styles.agentIndicator}`}>
<a className='no-underline mx-2 flex text-muted-foreground' href={constructAgentLink()} target="_blank" rel="noreferrer">
<Lightbulb color='orange' weight='fill' />

View File

@@ -61,8 +61,7 @@ div.author {
div.chatFooter {
display: flex;
justify-content: space-between;
margin-top: 8px;
justify-content: flex-end;
}
div.chatButtons {
@@ -127,4 +126,8 @@ div.trainOfThought.primary p {
max-width: 100%;
}
div.chatMessageWrapper {
padding-left: 8px;
}
}

View File

@@ -5,16 +5,14 @@ import styles from './chatMessage.module.css';
import markdownIt from 'markdown-it';
import mditHljs from "markdown-it-highlightjs";
import React, { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css'
import { hasValidReferences } from '../referencePanel/referencePanel';
import { ReferencePanelData, TeaserReferencesSection, constructAllReferences } from '../referencePanel/referencePanel';
import { ThumbsUp, ThumbsDown, Copy, Brain, Cloud, Folder, Book, Aperture } from '@phosphor-icons/react';
import { ThumbsUp, ThumbsDown, Copy, Brain, Cloud, Folder, Book, Aperture, ArrowRight, SpeakerHifi } from '@phosphor-icons/react';
import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr';
import { compare } from 'swr/_internal';
const md = new markdownIt({
html: true,
@@ -81,21 +79,22 @@ interface AgentData {
interface Intent {
type: string;
query: string;
"memory-type": string;
"inferred-queries": string[];
}
export interface SingleChatMessage {
automationId: string;
by: string;
intent: {
[key: string]: string
}
message: string;
context: Context[];
created: string;
onlineContext: {
[key: string]: OnlineContextData
}
rawQuery?: string;
intent?: Intent;
}
export interface StreamMessage {
@@ -118,29 +117,43 @@ export interface ChatHistoryData {
slug: string;
}
function FeedbackButtons() {
function sendFeedback(uquery: string, kquery: string, sentiment: string) {
fetch('/api/chat/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ uquery: uquery, kquery: kquery, sentiment: sentiment })
})
}
function FeedbackButtons({ uquery, kquery }: { uquery: string, kquery: string }) {
return (
<div className={`${styles.feedbackButtons} flex align-middle justify-center items-center`}>
<button className={styles.thumbsUpButton}>
<button className={styles.thumbsUpButton} onClick={() => sendFeedback(uquery, kquery, 'positive')}>
<ThumbsUp color='hsl(var(--muted-foreground))' />
</button>
<button className={styles.thumbsDownButton}>
<button className={styles.thumbsDownButton} onClick={() => sendFeedback(uquery, kquery, 'negative')}>
<ThumbsDown color='hsl(var(--muted-foreground))' />
</button>
</div>
)
}
function onClickMessage(event: React.MouseEvent<any>, chatMessage: SingleChatMessage, setReferencePanelData: Function, setShowReferencePanel: Function) {
// console.log("Clicked on message", chatMessage);
setReferencePanelData(chatMessage);
// WHAT TO DO WHEN CLICK ON KHOJ MESSAGE
function onClickMessage(
event: React.MouseEvent<any>,
referencePanelData: ReferencePanelData,
setReferencePanelData: Function,
setShowReferencePanel: Function) {
setReferencePanelData(referencePanelData);
setShowReferencePanel(true);
}
interface ChatMessageProps {
chatMessage: SingleChatMessage;
setReferencePanelData: Function;
setShowReferencePanel: Function;
isMobileWidth: boolean;
customClassName?: string;
borderLeftColor?: string;
}
@@ -183,6 +196,7 @@ function chooseIconFromHeader(header: string, iconColor: string) {
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(/\*\*(.*)\*\*/);
@@ -205,7 +219,7 @@ export default function ChatMessage(props: ChatMessageProps) {
// Replace LaTeX delimiters with placeholders
message = message.replace(/\\\(/g, 'LEFTPAREN').replace(/\\\)/g, 'RIGHTPAREN')
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
.replace(/\\\[/g, 'LEFTBRACKET').replace(/\\\]/g, 'RIGHTBRACKET');
if (props.chatMessage.intent && props.chatMessage.intent.type == "text-to-image2") {
message = `![generated_image](${message})\n\n${props.chatMessage.intent["inferred-queries"][0]}`
@@ -215,7 +229,7 @@ export default function ChatMessage(props: ChatMessageProps) {
// Replace placeholders with LaTeX delimiters
markdownRendered = markdownRendered.replace(/LEFTPAREN/g, '\\(').replace(/RIGHTPAREN/g, '\\)')
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
.replace(/LEFTBRACKET/g, '\\[').replace(/RIGHTBRACKET/g, '\\]');
const messageRef = useRef<HTMLDivElement>(null);
@@ -262,8 +276,6 @@ export default function ChatMessage(props: ChatMessageProps) {
}
}, [copySuccess]);
let referencesValid = hasValidReferences(props.chatMessage);
function constructClasses(chatMessage: SingleChatMessage) {
let classes = [styles.chatMessageContainer];
classes.push(styles[chatMessage.by]);
@@ -287,45 +299,57 @@ export default function ChatMessage(props: ChatMessageProps) {
return classes.join(' ');
}
const allReferences = constructAllReferences(props.chatMessage.context, props.chatMessage.onlineContext);
return (
<div
className={constructClasses(props.chatMessage)}
onClick={props.chatMessage.by === "khoj" ? (event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel) : undefined}>
{/* <div className={styles.chatFooter}> */}
{/* {props.chatMessage.by} */}
{/* </div> */}
<div className={chatMessageWrapperClasses(props.chatMessage)}>
<div ref={messageRef} className={styles.chatMessage} dangerouslySetInnerHTML={{ __html: markdownRendered }} />
{/* Add a copy button, thumbs up, and thumbs down buttons */}
<div className={styles.chatFooter}>
<div className={styles.chatTimestamp}>
{renderTimeStamp(props.chatMessage.created)}
</div>
<div className={styles.chatButtons}>
{
referencesValid &&
<div className={styles.referenceButton}>
<button onClick={(event) => onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel)}>
References
</button>
</div>
}
<button className={`${styles.copyButton}`} onClick={() => {
navigator.clipboard.writeText(props.chatMessage.message);
setCopySuccess(true);
}}>
{
copySuccess ?
<Copy color='green' />
: <Copy color='hsl(var(--muted-foreground))' />
}
onClick={props.chatMessage.by === "khoj" ? (event) => undefined : undefined}>
<div className={chatMessageWrapperClasses(props.chatMessage)}>
<div ref={messageRef} className={styles.chatMessage} dangerouslySetInnerHTML={{ __html: markdownRendered }} />
</div>
<div className={styles.teaserReferencesContainer}>
<TeaserReferencesSection
isMobileWidth={props.isMobileWidth}
notesReferenceCardData={allReferences.notesReferenceCardData}
onlineReferenceCardData={allReferences.onlineReferenceCardData} />
</div>
<div className={styles.chatFooter}>
{/* <div className={styles.chatTimestamp}>
{renderTimeStamp(props.chatMessage.created)}
</div> */}
<div className={styles.chatButtons}>
{
<div className={styles.referenceButton}>
<button onClick={(event) => console.log("speaker")}>
<SpeakerHifi color='hsl(var(--muted-foreground))' />
</button>
{
props.chatMessage.by === "khoj" && <FeedbackButtons />
}
</div>
</div>
}
<button className={`${styles.copyButton}`} onClick={() => {
navigator.clipboard.writeText(props.chatMessage.message);
setCopySuccess(true);
}}>
{
copySuccess ?
<Copy color='green' />
: <Copy color='hsl(var(--muted-foreground))' />
}
</button>
{
props.chatMessage.by === "khoj" &&
(
props.chatMessage.intent ?
<FeedbackButtons
uquery={props.chatMessage.intent.query}
kquery={props.chatMessage.message} />
: <FeedbackButtons
uquery={props.chatMessage.rawQuery || props.chatMessage.message}
kquery={props.chatMessage.message} />
)
}
</div>
</div>
</div>
)
}

View File

@@ -1,40 +0,0 @@
div.panel {
padding: 1rem;
border-radius: 1rem;
background-color: var(--calm-blue);
max-height: 80vh;
overflow-y: auto;
max-width: auto;
}
div.panel a {
text-decoration: underline;
}
div.onlineReference,
div.contextReference {
margin: 4px;
border-radius: 8px;
padding: 4px;
}
div.contextReference:hover {
cursor: pointer;
}
div.singleReference {
padding: 8px;
border-radius: 8px;
background-color: hsla(var(--frosted-background-color));
margin-top: 8px;
}
@media screen and (max-width: 768px) {
div.panel {
padding: 0.5rem;
}
div.singleReference {
padding: 4px;
}
}

View File

@@ -2,7 +2,9 @@
import styles from "./referencePanel.module.css";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ArrowRight, File } from "@phosphor-icons/react";
import markdownIt from "markdown-it";
const md = new markdownIt({
@@ -12,22 +14,335 @@ const md = new markdownIt({
});
import { SingleChatMessage, Context, WebPage, OnlineContextData } from "../chatMessage/chatMessage";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface ReferencePanelProps {
referencePanelData: SingleChatMessage | null;
setShowReferencePanel: (showReferencePanel: boolean) => void;
}
export function hasValidReferences(referencePanelData: SingleChatMessage | null) {
interface NotesContextReferenceData {
title: string;
content: string;
}
interface NotesContextReferenceCardProps extends NotesContextReferenceData {
showFullContent: boolean;
}
function NotesContextReferenceCard(props: NotesContextReferenceCardProps) {
const snippet = md.render(props.content);
const [isHovering, setIsHovering] = useState(false);
return (
referencePanelData &&
(
(referencePanelData.context && referencePanelData.context.length > 0) ||
(referencePanelData.onlineContext && Object.keys(referencePanelData.onlineContext).length > 0 &&
Object.values(referencePanelData.onlineContext).some(
(onlineContextData) =>
(onlineContextData.webpages && onlineContextData.webpages.length > 0) || onlineContextData.answerBox || onlineContextData.peopleAlsoAsk || onlineContextData.knowledgeGraph || onlineContextData.organic ))
)
<>
<Popover
open={isHovering && !props.showFullContent}
onOpenChange={setIsHovering}
>
<PopoverTrigger asChild>
<Card
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={`${props.showFullContent ? 'w-auto' : 'w-[200px]'} overflow-hidden break-words text-balance rounded-lg p-2 bg-muted border-none`}
>
<h3 className={`${props.showFullContent ? 'block' : 'line-clamp-1'} text-muted-foreground}`}>
<File className='w-6 h-6 text-muted-foreground inline-flex' />
{props.title}
</h3>
<p className={`${props.showFullContent ? 'block' : 'overflow-hidden line-clamp-2'}`} dangerouslySetInnerHTML={{ __html: snippet }}></p>
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[400px] mx-2">
<Card className={`w-auto overflow-hidden break-words text-balance rounded-lg p-2 border-none`}>
<h3 className={`line-clamp-2 text-muted-foreground}`}>
<File className='w-6 h-6 text-muted-foreground inline-flex' />
{props.title}
</h3>
<p className={`overflow-hidden line-clamp-3`} dangerouslySetInnerHTML={{ __html: snippet }}></p>
</Card>
</PopoverContent>
</Popover>
</>
)
}
export interface ReferencePanelData {
notesReferenceCardData: NotesContextReferenceData[];
onlineReferenceCardData: OnlineReferenceData[];
}
interface OnlineReferenceData {
title: string;
description: string;
link: string;
}
interface OnlineReferenceCardProps extends OnlineReferenceData {
showFullContent: boolean;
}
function GenericOnlineReferenceCard(props: OnlineReferenceCardProps) {
const domain = new URL(props.link).hostname;
const favicon = `https://www.google.com/s2/favicons?domain=${domain}`;
const [isHovering, setIsHovering] = useState(false);
const handleMouseEnter = () => {
console.log("mouse entered card");
setIsHovering(true);
}
const handleMouseLeave = () => {
console.log("mouse left card");
setIsHovering(false);
}
return (
<>
<Popover
open={isHovering && !props.showFullContent}
// open={true}
onOpenChange={setIsHovering}
>
<PopoverTrigger asChild>
<Card
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`${props.showFullContent ? 'w-auto' : 'w-[200px]'} overflow-hidden break-words rounded-lg text-balance p-2 bg-muted border-none`}>
<div className='flex flex-col'>
<a href={props.link} target="_blank" rel="noreferrer" className='!no-underline p-2'>
<div className='flex items-center'>
<img src={favicon} alt="" className='!w-4 h-4 mr-2' />
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-1'} text-muted-foreground`}>{domain}</h3>
</div>
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-1'} font-bold`}>{props.title}</h3>
<p className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'}`}>{props.description}</p>
</a>
</div>
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[400px] mx-2">
<Card
className={`w-auto overflow-hidden break-words text-balance rounded-lg border-none`}>
<div className='flex flex-col'>
<a href={props.link} target="_blank" rel="noreferrer" className='!no-underline p-2'>
<div className='flex items-center'>
<img src={favicon} alt="" className='!w-4 h-4 mr-2' />
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'} text-muted-foreground`}>{domain}</h3>
</div>
<h3 className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-2'} font-bold`}>{props.title}</h3>
<p className={`overflow-hidden ${props.showFullContent ? 'block' : 'line-clamp-3'}`}>{props.description}</p>
</a>
</div>
</Card>
</PopoverContent>
</Popover>
</>
)
}
export function constructAllReferences(contextData: Context[], onlineData: { [key: string]: OnlineContextData }) {
const onlineReferences: OnlineReferenceData[] = [];
const contextReferences: NotesContextReferenceData[] = [];
if (onlineData) {
let localOnlineReferences = [];
for (const [key, value] of Object.entries(onlineData)) {
if (value.answerBox) {
localOnlineReferences.push({
title: value.answerBox.title,
description: value.answerBox.answer,
link: value.answerBox.source
});
}
if (value.knowledgeGraph) {
localOnlineReferences.push({
title: value.knowledgeGraph.title,
description: value.knowledgeGraph.description,
link: value.knowledgeGraph.descriptionLink
});
}
if (value.webpages) {
// If webpages is of type Array, iterate through it and add each webpage to the localOnlineReferences array
if (value.webpages instanceof Array) {
let webPageResults = value.webpages.map((webPage) => {
return {
title: webPage.query,
description: webPage.snippet,
link: webPage.link
}
});
localOnlineReferences.push(...webPageResults);
} else {
let singleWebpage = value.webpages as WebPage;
// If webpages is an object, add the object to the localOnlineReferences array
localOnlineReferences.push({
title: singleWebpage.query,
description: singleWebpage.snippet,
link: singleWebpage.link
});
}
}
if (value.organic) {
let organicResults = value.organic.map((organicContext) => {
return {
title: organicContext.title,
description: organicContext.snippet,
link: organicContext.link
}
});
localOnlineReferences.push(...organicResults);
}
}
onlineReferences.push(...localOnlineReferences);
}
if (contextData) {
let localContextReferences = contextData.map((context) => {
if (!context.compiled) {
const fileContent = context as unknown as string;
const title = fileContent.split('\n')[0];
const content = fileContent.split('\n').slice(1).join('\n');
return {
title: title,
content: content
};
}
return {
title: context.file,
content: context.compiled
}
});
contextReferences.push(...localContextReferences);
}
return {
notesReferenceCardData: contextReferences,
onlineReferenceCardData: onlineReferences
}
}
export interface TeaserReferenceSectionProps {
notesReferenceCardData: NotesContextReferenceData[];
onlineReferenceCardData: OnlineReferenceData[];
isMobileWidth: boolean;
}
export function TeaserReferencesSection(props: TeaserReferenceSectionProps) {
const [numTeaserSlots, setNumTeaserSlots] = useState(3);
useEffect(() => {
setNumTeaserSlots(props.isMobileWidth ? 1 : 3);
}, [props.isMobileWidth]);
const notesDataToShow = props.notesReferenceCardData.slice(0, numTeaserSlots);
const onlineDataToShow = notesDataToShow.length < numTeaserSlots ? props.onlineReferenceCardData.slice(0, numTeaserSlots - notesDataToShow.length) : [];
const shouldShowShowMoreButton = props.notesReferenceCardData.length > 0 || props.onlineReferenceCardData.length > 0;
const numReferences = props.notesReferenceCardData.length + props.onlineReferenceCardData.length;
if (numReferences === 0) {
return null;
}
return (
<div className={`${props.isMobileWidth ? 'p-0' : 'p-4'}`}>
<h3 className="inline-flex items-center">
References
<p className="text-gray-400 m-2">
{numReferences} sources
</p>
</h3>
<div className={`flex ${props.isMobileWidth ? 'w-[90vw]' : 'w-auto'} space-x-4 mt-2`}>
{
notesDataToShow.map((note, index) => {
return <NotesContextReferenceCard showFullContent={false} {...note} key={`${note.title}-${index}`} />
})
}
{
onlineDataToShow.map((online, index) => {
return <GenericOnlineReferenceCard showFullContent={false} {...online} key={`${online.title}-${index}`} />
})
}
{
shouldShowShowMoreButton &&
<ReferencePanel
notesReferenceCardData={props.notesReferenceCardData}
onlineReferenceCardData={props.onlineReferenceCardData} />
}
</div>
</div>
)
}
interface ReferencePanelDataProps {
notesReferenceCardData: NotesContextReferenceData[];
onlineReferenceCardData: OnlineReferenceData[];
}
export default function ReferencePanel(props: ReferencePanelDataProps) {
if (!props.notesReferenceCardData && !props.onlineReferenceCardData) {
return null;
}
return (
<Sheet>
<SheetTrigger className='text-balance w-[200px] overflow-hidden break-words p-0 bg-transparent border-none text-gray-400 align-middle justify-center items-center !m-0 inline-flex'
onClick={() => { console.log("showing references") }}>
View references <ArrowRight className='m-1' />
</SheetTrigger>
<SheetContent className="overflow-y-scroll">
<SheetHeader>
<SheetTitle>References</SheetTitle>
<SheetDescription>View all references for this response</SheetDescription>
</SheetHeader>
<div className="flex flex-col w-auto gap-2 mt-2">
{
props.notesReferenceCardData.map((note, index) => {
return <NotesContextReferenceCard showFullContent={true} {...note} key={`${note.title}-${index}`} />
})
}
{
props.onlineReferenceCardData.map((online, index) => {
return <GenericOnlineReferenceCard showFullContent={true} {...online} key={`${online.title}-${index}`} />
})
}
</div>
</SheetContent>
</Sheet>
);
}
@@ -92,7 +407,7 @@ function WebPageReference(props: { webpages: WebPage, query: string | null }) {
)
}
function OnlineReferences(props: { onlineContext: OnlineContextData, query: string}) {
function OnlineReferences(props: { onlineContext: OnlineContextData, query: string }) {
const webpages = props.onlineContext.webpages;
const answerBox = props.onlineContext.answerBox;
@@ -183,30 +498,3 @@ function OnlineReferences(props: { onlineContext: OnlineContextData, query: stri
)
}
export default function ReferencePanel(props: ReferencePanelProps) {
if (!props.referencePanelData) {
return null;
}
if (!hasValidReferences(props.referencePanelData)) {
return null;
}
return (
<div className={`${styles.panel}`}>
References <button onClick={() => props.setShowReferencePanel(false)}>Hide</button>
{
props.referencePanelData?.context.map((context, index) => {
return <CompiledReference context={context} key={index} />
})
}
{
Object.entries(props.referencePanelData?.onlineContext || {}).map(([key, onlineContextData], index) => {
return <OnlineReferences onlineContext={onlineContextData} query={key} key={index} />
})
}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { UserProfile } from "@/app/common/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import useSWR from "swr";
import Image from "next/image";
import {
Collapsible,
@@ -15,6 +16,18 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { InlineLoading } from "../loading/loading";
import {
@@ -27,9 +40,21 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ArrowRight, ArrowLeft, ArrowDown, Spinner, Check } from "@phosphor-icons/react";
import { ArrowRight, ArrowLeft, ArrowDown, Spinner, Check, FolderPlus } from "@phosphor-icons/react";
interface ChatHistory {
conversation_id: string;
@@ -37,6 +62,7 @@ interface ChatHistory {
agent_name: string;
agent_avatar: string;
compressed: boolean;
created: string;
}
import {
@@ -58,6 +84,8 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { modifyFileFilterForConversation } from "@/app/common/chatFunctions";
import { ScrollAreaScrollbar } from "@radix-ui/react-scroll-area";
// Define a fetcher function
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@@ -121,70 +149,42 @@ function deleteConversation(conversationId: string) {
});
}
function modifyFileFilterForConversation(
conversationId: string | null,
filename: 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,
filename: filename,
}
const addUrl = `/api/chat/conversation/file-filters`;
fetch(addUrl, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
.then(response => response.json())
.then(data => {
setAddedFiles(data);
})
.catch(err => {
console.error(err);
return;
});
}
interface FilesMenuProps {
conversationId: string | null;
uploadedFiles: string[];
isMobileWidth: boolean;
}
function FilesMenu(props: FilesMenuProps) {
// Use SWR to fetch files
const { data: files, error } = useSWR(props.conversationId ? '/api/config/data/computer' : null, fetcher);
const { data: files, error } = useSWR<string[]>(props.conversationId ? '/api/config/data/computer' : null, fetcher);
const { data: selectedFiles, error: selectedFilesError } = useSWR(props.conversationId ? `/api/chat/conversation/file-filters/${props.conversationId}` : null, fetcher);
const [isOpen, setIsOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [filteredFiles, setFilteredFiles] = useState<string[]>([]);
const [unfilteredFiles, setUnfilteredFiles] = useState<string[]>([]);
const [addedFiles, setAddedFiles] = useState<string[]>([]);
useEffect(() => {
if (!files) return;
if (searchInput === '') {
setFilteredFiles(files);
} else {
let sortedFiles = files.filter((filename: string) => filename.toLowerCase().includes(searchInput.toLowerCase()));
if (addedFiles) {
sortedFiles = addedFiles.concat(filteredFiles.filter((filename: string) => !addedFiles.includes(filename)));
}
// First, sort lexically
files.sort();
let sortedFiles = files;
setFilteredFiles(sortedFiles);
if (addedFiles) {
console.log("addedFiles in useeffect hook", addedFiles);
sortedFiles = addedFiles.concat(sortedFiles.filter((filename: string) => !addedFiles.includes(filename)));
}
}, [searchInput, files, addedFiles]);
setUnfilteredFiles(sortedFiles);
}, [files, addedFiles]);
useEffect(() => {
for (const file of props.uploadedFiles) {
setAddedFiles((addedFiles) => [...addedFiles, file]);
}
}, [props.uploadedFiles]);
useEffect(() => {
if (selectedFiles) {
@@ -193,6 +193,14 @@ function FilesMenu(props: FilesMenuProps) {
}, [selectedFiles]);
const removeAllFiles = () => {
modifyFileFilterForConversation(props.conversationId, addedFiles, setAddedFiles, 'remove');
}
const addAllFiles = () => {
modifyFileFilterForConversation(props.conversationId, unfilteredFiles, setAddedFiles, 'add');
}
if (!props.conversationId) return (<></>);
if (error) return <div>Failed to load files</div>;
@@ -200,6 +208,88 @@ function FilesMenu(props: FilesMenuProps) {
if (!files) return <InlineLoading />;
if (!selectedFiles) return <InlineLoading />;
const FilesMenuCommandBox = () => {
return (
<Command>
<CommandInput placeholder="Find file" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Quick">
<CommandItem
onSelect={() => {
removeAllFiles();
}}
>
<Trash className="h-4 w-4 mr-2" />
<span>Clear all</span>
</CommandItem>
<CommandItem
onSelect={() => {
addAllFiles();
}}
>
<FolderPlus className="h-4 w-4 mr-2" />
<span>Select all</span>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Configure files">
{unfilteredFiles.map((filename: string) => (
addedFiles && addedFiles.includes(filename) ?
<CommandItem
key={filename}
value={filename}
className="bg-accent text-accent-foreground mb-1"
onSelect={(value) => {
modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'remove');
}}
>
<Check className="h-4 w-4 mr-2" />
<span className="break-all">{filename}</span>
</CommandItem>
:
<CommandItem
key={filename}
className="mb-1"
value={filename}
onSelect={(value) => {
modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'add');
}}
>
<span className="break-all">{filename}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
if (props.isMobileWidth) {
return (
<>
<Drawer>
<DrawerTrigger className="bg-background border border-muted p-4 rounded-2xl my-8 text-left inline-flex items-center justify-between w-full">
Manage Files <ArrowRight className="h-4 w-4 mx-2" />
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Files</DrawerTitle>
<DrawerDescription>Manage files for this conversation</DrawerDescription>
</DrawerHeader>
<div className={`${styles.panelWrapper}`}>
<FilesMenuCommandBox />
</div>
<DrawerFooter>
<DrawerClose>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
return (
<>
<Popover
@@ -207,10 +297,10 @@ function FilesMenu(props: FilesMenuProps) {
onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<div
className="w-auto bg-background border border-muted p-4 drop-shadow-sm rounded-2xl">
className="w-auto bg-background border border-muted p-4 drop-shadow-sm rounded-2xl my-8">
<div className="flex items-center justify-between space-x-4">
<h4 className="text-sm font-semibold">
Manage Files
Manage Context
<p>
<span className="text-muted-foreground text-xs">Using {addedFiles.length == 0 ? files.length : addedFiles.length} files</span>
</p>
@@ -228,40 +318,8 @@ function FilesMenu(props: FilesMenuProps) {
</div>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 mx-2">
<Input
placeholder="Find file"
className="rounded-md border-none py-2 text-sm text-wrap break-words my-2 bg-accent text-accent-foreground"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} />
{
filteredFiles.length === 0 && (
<div className="rounded-md border-none py-2 text-sm text-wrap break-words">
No files found
</div>
)
}
{
filteredFiles.map((filename: string) => (
addedFiles && addedFiles.includes(filename) ?
<Button
variant={'ghost'}
key={filename}
className="rounded-md border-none py-2 text-sm text-wrap break-words bg-accent text-accent-foreground text-left"
onClick={() => modifyFileFilterForConversation(props.conversationId, filename, setAddedFiles, 'remove')}>
{filename}
<Check className="h-4 w-4 ml-2" />
</Button>
:
<Button
variant={'ghost'}
key={filename}
className="rounded-md border-none py-2 text-sm text-wrap break-words text-left"
onClick={() => modifyFileFilterForConversation(props.conversationId, filename, setAddedFiles, 'add')}>
{filename}
</Button>
))
}
<PopoverContent className={`mx-2`}>
<FilesMenuCommandBox />
</PopoverContent>
</Popover>
</>
@@ -277,29 +335,24 @@ interface SessionsAndFilesProps {
data: ChatHistory[] | null;
userProfile: UserProfile | null;
conversationId: string | null;
uploadedFiles: string[];
isMobileWidth: boolean;
}
function SessionsAndFiles(props: SessionsAndFilesProps) {
return (
<>
<div className={`${styles.expanded}`}>
<button className={styles.button} onClick={() => props.setEnabled(false)}>
<ArrowLeft />
</button>
</div>
<ScrollArea className="h-[40vh] w-[14rem]">
<ScrollArea className="h-[40vh]">
<ScrollAreaScrollbar orientation="vertical" className="h-full w-2.5 border-l border-l-transparent p-[1px]" />
<div className={styles.sessionsList}>
{props.subsetOrganizedData != null && Object.keys(props.subsetOrganizedData).map((agentName) => (
<div key={agentName} className={`my-4`}>
{/* <h3 className={`grid grid-flow-col auto-cols-max gap-2 my-4 font-bold text-sm`}>
{
props.subsetOrganizedData &&
<img src={props.subsetOrganizedData[agentName][0].agent_avatar} alt={agentName} width={24} height={24} />
}
{agentName}
</h3> */}
{props.subsetOrganizedData && props.subsetOrganizedData[agentName].map((chatHistory) => (
{props.subsetOrganizedData != null && Object.keys(props.subsetOrganizedData).map((timeGrouping) => (
<div key={timeGrouping} className={`my-4`}>
<div className={`text-muted-foreground text-sm font-bold p-[0.5rem] `}>
{timeGrouping}
</div>
{props.subsetOrganizedData && props.subsetOrganizedData[timeGrouping].map((chatHistory) => (
<ChatSession
created={chatHistory.created}
compressed={true}
key={chatHistory.conversation_id}
conversation_id={chatHistory.conversation_id}
@@ -316,7 +369,7 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
<ChatSessionsModal data={props.organizedData} />
)
}
<FilesMenu conversationId={props.conversationId} />
<FilesMenu conversationId={props.conversationId} uploadedFiles={props.uploadedFiles} isMobileWidth={props.isMobileWidth} />
{props.userProfile &&
<UserProfileComponent userProfile={props.userProfile} webSocketConnected={props.webSocketConnected} collapsed={false} />
}</>
@@ -499,22 +552,23 @@ function ChatSessionsModal({ data }: ChatSessionsModalProps) {
return (
<Dialog>
<DialogTrigger
className="flex text-left text-medium text-gray-500 hover:text-gray-900 cursor-pointer my-4 text-sm">
Show All
className="flex text-left text-medium text-gray-500 hover:text-gray-900 cursor-pointer my-4 text-sm p-[0.5rem]">
See All
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>All Conversations</DialogTitle>
<DialogDescription>
<ScrollArea className="h-[500px] w-[450px] p-4">
{data && Object.keys(data).map((agentName) => (
<div key={agentName}>
<div className={`grid grid-flow-col auto-cols-max gap-2`}>
<img src={data[agentName][0].agent_avatar} alt={agentName} width={24} height={24} />
{agentName}
<DialogDescription
className="p-0">
<ScrollArea className="h-[500px] p-4">
{data && Object.keys(data).map((timeGrouping) => (
<div key={timeGrouping}>
<div className={`text-muted-foreground text-sm font-bold p-[0.5rem] `}>
{timeGrouping}
</div>
{data[agentName].map((chatHistory) => (
{data[timeGrouping].map((chatHistory) => (
<ChatSession
created={chatHistory.created}
compressed={false}
key={chatHistory.conversation_id}
conversation_id={chatHistory.conversation_id}
@@ -553,26 +607,26 @@ function UserProfileComponent(props: UserProfileProps) {
}
return (
<div className={styles.profile}>
<Link href="/config" target="_blank" rel="noopener noreferrer">
<div className={styles.profile}>
<Link href="/config" target="_blank" rel="noopener noreferrer">
<Avatar>
<AvatarImage src={props.userProfile.photo} alt="user profile" />
<AvatarFallback>
{props.userProfile.username[0]}
</AvatarFallback>
</Avatar>
</Link>
<div className={styles.profileDetails}>
<p>{props.userProfile?.username}</p>
{/* Connected Indicator */}
<div className="flex gap-2 items-center">
<div className={`inline-flex h-4 w-4 rounded-full opacity-75 ${props.webSocketConnected ? 'bg-green-500' : 'bg-rose-500'}`}></div>
<p className="text-muted-foreground text-sm">
{props.webSocketConnected ? "Connected" : "Disconnected"}
</p>
</div>
</Link>
<div className={styles.profileDetails}>
<p>{props.userProfile?.username}</p>
{/* Connected Indicator */}
<div className="flex gap-2 items-center">
<div className={`inline-flex h-4 w-4 rounded-full opacity-75 ${props.webSocketConnected ? 'bg-green-500' : 'bg-rose-500'}`}></div>
<p className="text-muted-foreground text-sm">
{props.webSocketConnected ? "Connected" : "Disconnected"}
</p>
</div>
</div>
</div>
);
}
@@ -600,6 +654,7 @@ export const useChatHistoryRecentFetchRequest = (url: string) => {
interface SidePanelProps {
webSocketConnected?: boolean;
conversationId: string | null;
uploadedFiles: string[];
}
@@ -614,6 +669,8 @@ export default function SidePanel(props: SidePanelProps) {
const { data: chatHistory } = useChatHistoryRecentFetchRequest('/api/chat/sessions');
const [isMobileWidth, setIsMobileWidth] = useState(false);
useEffect(() => {
if (chatHistory) {
setData(chatHistory);
@@ -621,27 +678,44 @@ export default function SidePanel(props: SidePanelProps) {
const groupedData: GroupedChatHistory = {};
const subsetOrganizedData: GroupedChatHistory = {};
let numAdded = 0;
const currentDate = new Date();
chatHistory.forEach((chatHistory) => {
if (!groupedData[chatHistory.agent_name]) {
groupedData[chatHistory.agent_name] = [];
const chatDate = new Date(chatHistory.created);
const diffTime = Math.abs(currentDate.getTime() - chatDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const timeGrouping = diffDays < 7 ? 'Recent' : diffDays < 30 ? 'Last Month' : 'All Time';
if (!groupedData[timeGrouping]) {
groupedData[timeGrouping] = [];
}
groupedData[chatHistory.agent_name].push(chatHistory);
groupedData[timeGrouping].push(chatHistory);
// Add to subsetOrganizedData if less than 8
if (numAdded < 8) {
if (!subsetOrganizedData[chatHistory.agent_name]) {
subsetOrganizedData[chatHistory.agent_name] = [];
if (!subsetOrganizedData[timeGrouping]) {
subsetOrganizedData[timeGrouping] = [];
}
subsetOrganizedData[chatHistory.agent_name].push(chatHistory);
subsetOrganizedData[timeGrouping].push(chatHistory);
numAdded++;
}
});
setSubsetOrganizedData(subsetOrganizedData);
setOrganizedData(groupedData);
}
}, [chatHistory]);
useEffect(() => {
if (window.innerWidth < 768) {
setIsMobileWidth(true);
}
window.addEventListener('resize', () => {
setIsMobileWidth(window.innerWidth < 768);
});
fetch('/api/v1/user', { method: 'GET' })
.then(response => response.json())
@@ -655,31 +729,66 @@ export default function SidePanel(props: SidePanelProps) {
}, []);
return (
<div className={`${styles.panel}`}>
<div className={`${styles.panel} ${enabled ? styles.expanded : styles.collapsed}`}>
<div className="flex items-start justify-between">
<Image src="khoj-logo.svg"
alt="logo"
width={40}
height={40}
/>
{/* <button className={styles.button} onClick={() => setEnabled(!enabled)}>
{enabled ? <ArrowLeft className="h-4 w-4" /> : <ArrowRight className="h-4 w-4 mx-2" />}
</button> */}
{
isMobileWidth ?
<Drawer>
<DrawerTrigger><ArrowRight className="h-4 w-4 mx-2" /></DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Sessions and Files</DrawerTitle>
<DrawerDescription>View all conversation sessions and manage conversation file filters</DrawerDescription>
</DrawerHeader>
<div className={`${styles.panelWrapper}`}>
<SessionsAndFiles
webSocketConnected={props.webSocketConnected}
setEnabled={setEnabled}
subsetOrganizedData={subsetOrganizedData}
organizedData={organizedData}
data={data}
uploadedFiles={props.uploadedFiles}
userProfile={userProfile}
conversationId={props.conversationId}
isMobileWidth={isMobileWidth}
/>
</div>
<DrawerFooter>
<DrawerClose>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
:
<button className={styles.button} onClick={() => setEnabled(!enabled)}>
{enabled ? <ArrowLeft className="h-4 w-4" /> : <ArrowRight className="h-4 w-4 mx-2" />}
</button>
}
</div>
{
enabled ?
<div className={`${styles.panelWrapper}`}>
<SessionsAndFiles
webSocketConnected={props.webSocketConnected}
setEnabled={setEnabled}
subsetOrganizedData={subsetOrganizedData}
organizedData={organizedData}
data={data}
userProfile={userProfile}
conversationId={props.conversationId}
/>
</div>
:
<div>
<div className={`${styles.collapsed}`}>
<button className={styles.button} onClick={() => setEnabled(true)}>
<ArrowRight />
</button>
{userProfile &&
<UserProfileComponent userProfile={userProfile} webSocketConnected={props.webSocketConnected} collapsed={true} />
}
</div>
</div>
enabled &&
<div className={`${styles.panelWrapper}`}>
<SessionsAndFiles
webSocketConnected={props.webSocketConnected}
setEnabled={setEnabled}
subsetOrganizedData={subsetOrganizedData}
organizedData={organizedData}
data={data}
uploadedFiles={props.uploadedFiles}
userProfile={userProfile}
conversationId={props.conversationId}
isMobileWidth={isMobileWidth}
/>
</div>
}
</div>

View File

@@ -41,19 +41,18 @@ button.showMoreButton {
}
div.panel {
display: grid;
grid-auto-flow: row;
display: flex;
flex-direction: column;
padding: 1rem;
background-color: hsla(var(--muted));
height: 100%;
overflow-y: auto;
max-width: auto;
transition: background-color 0.5s;
}
div.expanded {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
background-color: hsla(var(--muted));
height: 100%;
}
div.collapsed {
@@ -83,8 +82,10 @@ div.profile {
}
div.panelWrapper {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto 1fr auto auto;
width: 70%;
height: 100%;
}
@@ -119,9 +120,29 @@ div.modalSessionsList div.session {
@media screen and (max-width: 768px) {
div.panel {
padding: 0.5rem;
position: absolute;
width: 100%;
}
div.expanded {
z-index: 1;
}
div.singleReference {
padding: 4px;
}
div.panelWrapper {
width: 100%;
}
div.session.compressed {
max-width: 100%;
grid-template-columns: minmax(auto, 350px) 1fr;
}
div.session {
max-width: 100%;
grid-template-columns: 200px 1fr;
}
}