From 6f8a65c529f105eca66369f0cdae95e4c54c6613 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sun, 7 Jul 2024 15:42:29 +0530 Subject: [PATCH] References, mobile friendly chat sessions and file filter --- src/interface/web/app/chat/page.tsx | 123 +++++- src/interface/web/app/common/chatFunctions.ts | 209 +++++++-- .../components/chatHistory/chatHistory.tsx | 46 +- .../chatMessage/chatMessage.module.css | 7 +- .../components/chatMessage/chatMessage.tsx | 128 +++--- .../referencePanel/referencePanel.module.css | 40 -- .../referencePanel/referencePanel.tsx | 364 +++++++++++++-- .../sidePanel/chatHistorySidePanel.tsx | 417 +++++++++++------- .../components/sidePanel/sidePanel.module.css | 37 +- src/interface/web/components/ui/command.tsx | 155 +++++++ src/interface/web/components/ui/drawer.tsx | 118 +++++ src/interface/web/components/ui/progress.tsx | 32 ++ src/interface/web/components/ui/sheet.tsx | 140 ++++++ src/interface/web/package.json | 5 +- src/interface/web/tsconfig.json | 1 + src/interface/web/yarn.lock | 182 +++++++- src/khoj/database/adapters/__init__.py | 28 ++ src/khoj/routers/api_chat.py | 63 +-- src/khoj/routers/web_client.py | 12 + src/khoj/utils/rawconfig.py | 7 +- 20 files changed, 1732 insertions(+), 382 deletions(-) create mode 100644 src/interface/web/components/ui/command.tsx create mode 100644 src/interface/web/components/ui/drawer.tsx create mode 100644 src/interface/web/components/ui/progress.tsx create mode 100644 src/interface/web/components/ui/sheet.tsx diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx index d0df1a07..f73567a5 100644 --- a/src/interface/web/app/chat/page.tsx +++ b/src/interface/web/app/chat/page.tsx @@ -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(null); + + const [warning, setWarning] = useState(null); + const [error, setError] = useState(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) { + if (!event.target.files) return; + + uploadDataForIndexing( + event.target.files, + setWarning, + setUploading, + setError, + props.setUploadedFiles, + props.conversationId); + } + return ( <> + { + 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 + + + ) + } +
@@ -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 ( <>
- +
- setMessage(message)} sendDisabled={processingMessage} /> + setMessage(message)} + sendDisabled={processingMessage} + conversationId={conversationId} + setUploadedFiles={props.setUploadedFiles} />
); @@ -171,6 +280,7 @@ export default function Chat() { const [messages, setMessages] = useState([]); const [queryToProcess, setQueryToProcess] = useState(''); const [processQuerySignal, setProcessQuerySignal] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); const handleWebSocketMessage = (event: MessageEvent) => { @@ -318,7 +428,7 @@ export default function Chat() { {title}
- +
@@ -329,6 +439,7 @@ export default function Chat() { chatOptionsData={chatOptionsData} setTitle={setTitle} setQueryToProcess={setQueryToProcess} + setUploadedFiles={setUploadedFiles} onConversationIdChange={handleConversationIdChange} />
diff --git a/src/interface/web/app/common/chatFunctions.ts b/src/interface/web/app/common/chatFunctions.ts index a3f3cdd8..60254692 100644 --- a/src/interface/web/app/common/chatFunctions.ts +++ b/src/interface/web/app/common/chatFunctions.ts @@ -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((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/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx index ce844c7e..e8a70d68 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.tsx +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -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(null); const sentinelRef = useRef(null); - const [showReferencePanel, setShowReferencePanel] = useState(true); - const [referencePanelData, setReferencePanelData] = useState(null); const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState(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) => ( @@ -242,6 +248,7 @@ export default function ChatHistory(props: ChatHistoryProps) { <> { }} - 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) } @@ -287,6 +293,7 @@ export default function ChatHistory(props: ChatHistoryProps) { props.pendingMessage && { }} - setShowReferencePanel={() => { }} customClassName='fullHistory' borderLeftColor='orange-500' /> } - { - (hasValidReferences(referencePanelData) && showReferencePanel) && - - }
diff --git a/src/interface/web/app/components/chatMessage/chatMessage.module.css b/src/interface/web/app/components/chatMessage/chatMessage.module.css index f0d80130..3343c674 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.module.css +++ b/src/interface/web/app/components/chatMessage/chatMessage.module.css @@ -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; + } + } diff --git a/src/interface/web/app/components/chatMessage/chatMessage.tsx b/src/interface/web/app/components/chatMessage/chatMessage.tsx index e4c7ca56..79b008ff 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.tsx +++ b/src/interface/web/app/components/chatMessage/chatMessage.tsx @@ -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 (
- -
) } -function onClickMessage(event: React.MouseEvent, 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, + 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 ; } + 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(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 (
onClickMessage(event, props.chatMessage, props.setReferencePanelData, props.setShowReferencePanel) : undefined}> - {/*
*/} - {/* {props.chatMessage.by} */} - {/*
*/} -
-
- {/* Add a copy button, thumbs up, and thumbs down buttons */} -
-
- {renderTimeStamp(props.chatMessage.created)} -
-
- { - referencesValid && -
- -
- } - - { - props.chatMessage.by === "khoj" && - }
-
+ } + + { + props.chatMessage.by === "khoj" && + ( + props.chatMessage.intent ? + + : + ) + }
+
) } diff --git a/src/interface/web/app/components/referencePanel/referencePanel.module.css b/src/interface/web/app/components/referencePanel/referencePanel.module.css index 83ca4ab8..e69de29b 100644 --- a/src/interface/web/app/components/referencePanel/referencePanel.module.css +++ b/src/interface/web/app/components/referencePanel/referencePanel.module.css @@ -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; - } -} diff --git a/src/interface/web/app/components/referencePanel/referencePanel.tsx b/src/interface/web/app/components/referencePanel/referencePanel.tsx index 76e7cd7d..fddff7e1 100644 --- a/src/interface/web/app/components/referencePanel/referencePanel.tsx +++ b/src/interface/web/app/components/referencePanel/referencePanel.tsx @@ -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 )) - ) + <> + + + 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`} + > +

+ + {props.title} +

+

+
+
+ + +

+ + {props.title} +

+

+
+
+
+ + ) +} + +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 ( + <> + + + + +
+ + + + + + + + + + + + ) +} + +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 ( +
+

+ References +

+ {numReferences} sources +

+

+
+ { + notesDataToShow.map((note, index) => { + return + }) + } + { + onlineDataToShow.map((online, index) => { + return + }) + } + { + shouldShowShowMoreButton && + + } +
+
+ ) +} + + +interface ReferencePanelDataProps { + notesReferenceCardData: NotesContextReferenceData[]; + onlineReferenceCardData: OnlineReferenceData[]; +} + +export default function ReferencePanel(props: ReferencePanelDataProps) { + + if (!props.notesReferenceCardData && !props.onlineReferenceCardData) { + return null; + } + + return ( + + { console.log("showing references") }}> + View references + + + + References + View all references for this response + +
+ { + props.notesReferenceCardData.map((note, index) => { + return + }) + } + { + props.onlineReferenceCardData.map((online, index) => { + return + }) + } +
+
+
); } @@ -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 ( -
- References - { - props.referencePanelData?.context.map((context, index) => { - return - }) - } - { - Object.entries(props.referencePanelData?.onlineContext || {}).map(([key, onlineContextData], index) => { - return - }) - } -
- ); -} diff --git a/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx b/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx index e0b0880f..e2c52d59 100644 --- a/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx +++ b/src/interface/web/app/components/sidePanel/chatHistorySidePanel.tsx @@ -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(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([]); + const [unfilteredFiles, setUnfilteredFiles] = useState([]); const [addedFiles, setAddedFiles] = useState([]); 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
Failed to load files
; @@ -200,6 +208,88 @@ function FilesMenu(props: FilesMenuProps) { if (!files) return ; if (!selectedFiles) return ; + const FilesMenuCommandBox = () => { + return ( + + + + No results found. + + { + removeAllFiles(); + }} + > + + Clear all + + { + addAllFiles(); + }} + > + + Select all + + + + {unfilteredFiles.map((filename: string) => ( + addedFiles && addedFiles.includes(filename) ? + { + modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'remove'); + }} + > + + {filename} + + : + { + modifyFileFilterForConversation(props.conversationId, [value], setAddedFiles, 'add'); + }} + > + {filename} + + ))} + + + + ); + } + + if (props.isMobileWidth) { + return ( + <> + + + Manage Files + + + + Files + Manage files for this conversation + +
+ +
+ + + + + +
+
+ + ); + } + return ( <>
+ className="w-auto bg-background border border-muted p-4 drop-shadow-sm rounded-2xl my-8">

- Manage Files + Manage Context

Using {addedFiles.length == 0 ? files.length : addedFiles.length} files

@@ -228,40 +318,8 @@ function FilesMenu(props: FilesMenuProps) {

- - setSearchInput(e.target.value)} /> - { - filteredFiles.length === 0 && ( -
- No files found -
- ) - } - { - filteredFiles.map((filename: string) => ( - addedFiles && addedFiles.includes(filename) ? - - : - - )) - } + +
@@ -277,29 +335,24 @@ interface SessionsAndFilesProps { data: ChatHistory[] | null; userProfile: UserProfile | null; conversationId: string | null; + uploadedFiles: string[]; + isMobileWidth: boolean; } function SessionsAndFiles(props: SessionsAndFilesProps) { return ( <> -
- -
- + +
- {props.subsetOrganizedData != null && Object.keys(props.subsetOrganizedData).map((agentName) => ( -
- {/*

- { - props.subsetOrganizedData && - {agentName} - } - {agentName} -

*/} - {props.subsetOrganizedData && props.subsetOrganizedData[agentName].map((chatHistory) => ( + {props.subsetOrganizedData != null && Object.keys(props.subsetOrganizedData).map((timeGrouping) => ( +
+
+ {timeGrouping} +
+ {props.subsetOrganizedData && props.subsetOrganizedData[timeGrouping].map((chatHistory) => ( ) } - + {props.userProfile && } @@ -499,22 +552,23 @@ function ChatSessionsModal({ data }: ChatSessionsModalProps) { return ( - 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 All Conversations - - - {data && Object.keys(data).map((agentName) => ( -
-
- {agentName} - {agentName} + + + {data && Object.keys(data).map((timeGrouping) => ( +
+
+ {timeGrouping}
- {data[agentName].map((chatHistory) => ( + {data[timeGrouping].map((chatHistory) => ( - +
+ {props.userProfile.username[0]} - -
-

{props.userProfile?.username}

- {/* Connected Indicator */} -
-
-

- {props.webSocketConnected ? "Connected" : "Disconnected"} -

-
+ +
+

{props.userProfile?.username}

+ {/* Connected Indicator */} +
+
+

+ {props.webSocketConnected ? "Connected" : "Disconnected"} +

+
); } @@ -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 ( -
+
+
+ logo + {/* */} + { + isMobileWidth ? + + + + + Sessions and Files + View all conversation sessions and manage conversation file filters + +
+ +
+ + + + + +
+
+ : + + } +
{ - enabled ? -
- -
- : -
-
- - {userProfile && - - } -
-
+ enabled && +
+ +
}
diff --git a/src/interface/web/app/components/sidePanel/sidePanel.module.css b/src/interface/web/app/components/sidePanel/sidePanel.module.css index 81d04ad5..ee90d5fc 100644 --- a/src/interface/web/app/components/sidePanel/sidePanel.module.css +++ b/src/interface/web/app/components/sidePanel/sidePanel.module.css @@ -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; + } } diff --git a/src/interface/web/components/ui/command.tsx b/src/interface/web/components/ui/command.tsx new file mode 100644 index 00000000..007798a1 --- /dev/null +++ b/src/interface/web/components/ui/command.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/interface/web/components/ui/drawer.tsx b/src/interface/web/components/ui/drawer.tsx new file mode 100644 index 00000000..6a0ef53d --- /dev/null +++ b/src/interface/web/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/interface/web/components/ui/progress.tsx b/src/interface/web/components/ui/progress.tsx new file mode 100644 index 00000000..f0c3c29e --- /dev/null +++ b/src/interface/web/components/ui/progress.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +interface ProgressProps extends React.ComponentPropsWithoutRef { + indicatorColor?: string +} + +const Progress = React.forwardRef< + React.ElementRef, + ProgressProps +>(({ className, value, indicatorColor, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/interface/web/components/ui/sheet.tsx b/src/interface/web/components/ui/sheet.tsx new file mode 100644 index 00000000..a37f17ba --- /dev/null +++ b/src/interface/web/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/interface/web/package.json b/src/interface/web/package.json index b38dfa37..4e20159b 100644 --- a/src/interface/web/package.json +++ b/src/interface/web/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", @@ -35,6 +36,7 @@ "autoprefixer": "^10.4.19", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "katex": "^0.16.10", "lucide-react": "^0.397.0", "markdown-it": "^14.1.0", @@ -47,7 +49,8 @@ "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "tailwindcss": "^3.4.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.1" }, "devDependencies": { "@types/node": "^20", diff --git a/src/interface/web/tsconfig.json b/src/interface/web/tsconfig.json index ee33bd89..d1e17ddf 100644 --- a/src/interface/web/tsconfig.json +++ b/src/interface/web/tsconfig.json @@ -7,6 +7,7 @@ ], "allowJs": true, "skipLibCheck": true, + "downlevelIteration": true, "strict": true, "noEmit": true, "esModuleInterop": true, diff --git a/src/interface/web/yarn.lock b/src/interface/web/yarn.lock index baf44ac8..54b30ab8 100644 --- a/src/interface/web/yarn.lock +++ b/src/interface/web/yarn.lock @@ -245,7 +245,7 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-typescript" "^7.24.7" -"@babel/runtime@^7.23.2", "@babel/runtime@^7.24.1": +"@babel/runtime@^7.13.10", "@babel/runtime@^7.23.2", "@babel/runtime@^7.24.1": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== @@ -501,6 +501,13 @@ resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== +"@radix-ui/primitive@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" + integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2" @@ -559,17 +566,52 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-compose-refs@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" + integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74" integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw== +"@radix-ui/react-context@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" + integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-context@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.0.tgz#6df8d983546cfd1999c8512f3a8ad85a6e7fcee8" integrity sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A== -"@radix-ui/react-dialog@1.1.1", "@radix-ui/react-dialog@^1.1.1": +"@radix-ui/react-dialog@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" + integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + +"@radix-ui/react-dialog@1.1.1", "@radix-ui/react-dialog@^1.0.4", "@radix-ui/react-dialog@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz#4906507f7b4ad31e22d7dad69d9330c87c431d44" integrity sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg== @@ -594,6 +636,18 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc" integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg== +"@radix-ui/react-dismissable-layer@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4" + integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-escape-keydown" "1.0.3" + "@radix-ui/react-dismissable-layer@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz#2cd0a49a732372513733754e6032d3fb7988834e" @@ -618,11 +672,28 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-focus-guards@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" + integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-focus-guards@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz#8e9abb472a9a394f59a1b45f3dd26cfe3fc6da13" integrity sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw== +"@radix-ui/react-focus-scope@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525" + integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-focus-scope@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2" @@ -632,6 +703,14 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-id@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0" + integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-id@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" @@ -743,6 +822,14 @@ "@radix-ui/react-use-size" "1.1.0" "@radix-ui/rect" "1.1.0" +"@radix-ui/react-portal@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" + integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-portal@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.1.tgz#1957f1eb2e1aedfb4a5475bd6867d67b50b1d15f" @@ -751,6 +838,15 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-presence@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba" + integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-presence@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.0.tgz#227d84d20ca6bfe7da97104b1a8b48a833bfb478" @@ -759,6 +855,14 @@ "@radix-ui/react-compose-refs" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-primitive@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" + integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-primitive@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884" @@ -766,6 +870,14 @@ dependencies: "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-progress@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.1.0.tgz#28c267885ec154fc557ec7a66cb462787312f7e2" + integrity sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg== + dependencies: + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-roving-focus@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz#b30c59daf7e714c748805bfe11c76f96caaac35e" @@ -796,6 +908,14 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-slot@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" + integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-slot@1.1.0", "@radix-ui/react-slot@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84" @@ -812,11 +932,26 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-use-callback-ref@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" + integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw== +"@radix-ui/react-use-controllable-state@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" + integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0" @@ -824,6 +959,14 @@ dependencies: "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-use-escape-keydown@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755" + integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-escape-keydown@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754" @@ -831,6 +974,13 @@ dependencies: "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-use-layout-effect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" + integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" @@ -1424,6 +1574,14 @@ clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +cmdk@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.0.0.tgz#0a095fdafca3dfabed82d1db78a6262fb163ded9" + integrity sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q== + dependencies: + "@radix-ui/react-dialog" "1.0.5" + "@radix-ui/react-primitive" "1.0.3" + code-block-writer@^12.0.0: version "12.0.0" resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-12.0.0.tgz#4dd58946eb4234105aff7f0035977b2afdc2a770" @@ -3427,7 +3585,7 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-remove-scroll-bar@^2.3.4: +react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.4: version "2.3.6" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== @@ -3435,6 +3593,17 @@ react-remove-scroll-bar@^2.3.4: react-style-singleton "^2.2.1" tslib "^2.0.0" +react-remove-scroll@2.5.5: + version "2.5.5" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" + integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw== + dependencies: + react-remove-scroll-bar "^2.3.3" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + react-remove-scroll@2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz#15a1fd038e8497f65a695bf26a4a57970cac1ccb" @@ -4147,6 +4316,13 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +vaul@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/vaul/-/vaul-0.9.1.tgz#3640198e04636b209b1f907fcf3079bec6ecc66b" + integrity sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw== + dependencies: + "@radix-ui/react-dialog" "^1.0.4" + wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index fb64f3b0..01869999 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -909,6 +909,34 @@ class ConversationAdapters: async def aget_text_to_image_model_config(): return await TextToImageModelConfig.objects.filter().afirst() + @staticmethod + def add_files_to_filter(user: KhojUser, conversation_id: int, files: List[str]): + conversation = ConversationAdapters.get_conversation_by_user(user, conversation_id=conversation_id) + file_list = EntryAdapters.get_all_filenames_by_source(user, "computer") + for filename in files: + if filename in file_list and filename not in conversation.file_filters: + conversation.file_filters.append(filename) + conversation.save() + + # remove files from conversation.file_filters that are not in file_list + conversation.file_filters = [file for file in conversation.file_filters if file in file_list] + conversation.save() + return conversation.file_filters + + @staticmethod + def remove_files_from_filter(user: KhojUser, conversation_id: int, files: List[str]): + conversation = ConversationAdapters.get_conversation_by_user(user, conversation_id=conversation_id) + for filename in files: + if filename in conversation.file_filters: + conversation.file_filters.remove(filename) + conversation.save() + + # remove files from conversation.file_filters that are not in file_list + file_list = EntryAdapters.get_all_filenames_by_source(user, "computer") + conversation.file_filters = [file for file in conversation.file_filters if file in file_list] + conversation.save() + return conversation.file_filters + class FileObjectAdapters: @staticmethod diff --git a/src/khoj/routers/api_chat.py b/src/khoj/routers/api_chat.py index eaac422c..123ab87d 100644 --- a/src/khoj/routers/api_chat.py +++ b/src/khoj/routers/api_chat.py @@ -60,7 +60,7 @@ from khoj.utils.helpers import ( get_device, is_none_or_empty, ) -from khoj.utils.rawconfig import FilterRequest, LocationData +from khoj.utils.rawconfig import FileFilterRequest, FilesFilterRequest, LocationData # Initialize Router logger = logging.getLogger(__name__) @@ -94,21 +94,36 @@ def get_file_filter(request: Request, conversation_id: str) -> Response: return Response(content=json.dumps(file_filters), media_type="application/json", status_code=200) +@api_chat.delete("/conversation/file-filters/bulk", response_class=Response) +@requires(["authenticated"]) +def remove_files_filter(request: Request, filter: FilesFilterRequest) -> Response: + conversation_id = int(filter.conversation_id) + files_filter = filter.filenames + file_filters = ConversationAdapters.remove_files_from_filter(request.user.object, conversation_id, files_filter) + return Response(content=json.dumps(file_filters), media_type="application/json", status_code=200) + + +@api_chat.post("/conversation/file-filters/bulk", response_class=Response) +@requires(["authenticated"]) +def add_files_filter(request: Request, filter: FilesFilterRequest): + try: + conversation_id = int(filter.conversation_id) + files_filter = filter.filenames + file_filters = ConversationAdapters.add_files_to_filter(request.user.object, conversation_id, files_filter) + return Response(content=json.dumps(file_filters), media_type="application/json", status_code=200) + except Exception as e: + logger.error(f"Error adding file filter {filter.filename}: {e}", exc_info=True) + raise HTTPException(status_code=422, detail=str(e)) + + @api_chat.post("/conversation/file-filters", response_class=Response) @requires(["authenticated"]) -def add_file_filter(request: Request, filter: FilterRequest): +def add_file_filter(request: Request, filter: FileFilterRequest): try: - conversation = ConversationAdapters.get_conversation_by_user( - request.user.object, conversation_id=int(filter.conversation_id) - ) - file_list = EntryAdapters.get_all_filenames_by_source(request.user.object, "computer") - if filter.filename in file_list and filter.filename not in conversation.file_filters: - conversation.file_filters.append(filter.filename) - conversation.save() - # remove files from conversation.file_filters that are not in file_list - conversation.file_filters = [file for file in conversation.file_filters if file in file_list] - conversation.save() - return Response(content=json.dumps(conversation.file_filters), media_type="application/json", status_code=200) + conversation_id = int(filter.conversation_id) + files_filter = [filter.filename] + file_filters = ConversationAdapters.add_files_to_filter(request.user.object, conversation_id, files_filter) + return Response(content=json.dumps(file_filters), media_type="application/json", status_code=200) except Exception as e: logger.error(f"Error adding file filter {filter.filename}: {e}", exc_info=True) raise HTTPException(status_code=422, detail=str(e)) @@ -116,18 +131,11 @@ def add_file_filter(request: Request, filter: FilterRequest): @api_chat.delete("/conversation/file-filters", response_class=Response) @requires(["authenticated"]) -def remove_file_filter(request: Request, filter: FilterRequest) -> Response: - conversation = ConversationAdapters.get_conversation_by_user( - request.user.object, conversation_id=int(filter.conversation_id) - ) - if filter.filename in conversation.file_filters: - conversation.file_filters.remove(filter.filename) - conversation.save() - # remove files from conversation.file_filters that are not in file_list - file_list = EntryAdapters.get_all_filenames_by_source(request.user.object, "computer") - conversation.file_filters = [file for file in conversation.file_filters if file in file_list] - conversation.save() - return Response(content=json.dumps(conversation.file_filters), media_type="application/json", status_code=200) +def remove_file_filter(request: Request, filter: FileFilterRequest) -> Response: + conversation_id = int(filter.conversation_id) + files_filter = [filter.filename] + file_filters = ConversationAdapters.remove_files_from_filter(request.user.object, conversation_id, files_filter) + return Response(content=json.dumps(file_filters), media_type="application/json", status_code=200) class FeedbackData(BaseModel): @@ -379,7 +387,9 @@ def chat_sessions( if recent: conversations = conversations[:8] - sessions = conversations.values_list("id", "slug", "title", "agent__slug", "agent__name", "agent__avatar") + sessions = conversations.values_list( + "id", "slug", "title", "agent__slug", "agent__name", "agent__avatar", "created_at" + ) session_values = [ { @@ -387,6 +397,7 @@ def chat_sessions( "slug": session[2] or session[1], "agent_name": session[4], "agent_avatar": session[5], + "created": session[6].strftime("%Y-%m-%d %H:%M:%S"), } for session in sessions ] diff --git a/src/khoj/routers/web_client.py b/src/khoj/routers/web_client.py index e93d1ea3..bd945fa3 100644 --- a/src/khoj/routers/web_client.py +++ b/src/khoj/routers/web_client.py @@ -41,6 +41,12 @@ templates = Jinja2Templates([constants.web_directory, constants.next_js_director @web_client.get("/", response_class=FileResponse) @requires(["authenticated"], redirect="login_page") def index(request: Request): + return templates.TemplateResponse( + "chat/index.html", + context={ + "request": request, + }, + ) user = request.user.object user_picture = request.session.get("user", {}).get("picture") has_documents = EntryAdapters.user_has_entries(user=user) @@ -101,6 +107,12 @@ def search_page(request: Request): @web_client.get("/chat", response_class=FileResponse) @requires(["authenticated"], redirect="login_page") def chat_page(request: Request): + return templates.TemplateResponse( + "chat/index.html", + context={ + "request": request, + }, + ) user = request.user.object user_picture = request.session.get("user", {}).get("picture") has_documents = EntryAdapters.user_has_entries(user=user) diff --git a/src/khoj/utils/rawconfig.py b/src/khoj/utils/rawconfig.py index b4bdcbea..79326e68 100644 --- a/src/khoj/utils/rawconfig.py +++ b/src/khoj/utils/rawconfig.py @@ -27,11 +27,16 @@ class LocationData(BaseModel): country: Optional[str] -class FilterRequest(BaseModel): +class FileFilterRequest(BaseModel): filename: str conversation_id: str +class FilesFilterRequest(BaseModel): + filenames: List[str] + conversation_id: str + + class TextConfigBase(ConfigBase): compressed_jsonl: Path embeddings_file: Path