diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index b9a5322d..8fa09a23 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -31,6 +31,7 @@ interface ChatMessageState { rawResponse: string; rawQuery: string; isVoice: boolean; + turnId: string; } interface Location { @@ -41,6 +42,17 @@ interface Location { timezone: string; } +interface RenderMessageOptions { + chatBodyEl: Element; + message: string; + sender: string; + turnId?: string; + dt?: Date; + raw?: boolean; + willReplace?: boolean; + isSystemMessage?: boolean; +} + export class KhojChatView extends KhojPaneView { result: string; setting: KhojSetting; @@ -480,6 +492,7 @@ export class KhojChatView extends KhojPaneView { chatEl: Element, message: string, sender: string, + turnId: string, context?: string[], onlineContext?: object, dt?: Date, @@ -500,9 +513,21 @@ export class KhojChatView extends KhojPaneView { mermaidjsDiagram || excalidrawDiagram) { let imageMarkdown = this.generateImageMarkdown(message, intentType ?? "", inferredQueries, conversationId, images, excalidrawDiagram, mermaidjsDiagram); - chatMessageEl = this.renderMessage(chatEl, imageMarkdown, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message: imageMarkdown, + sender, + dt, + turnId + }); } else { - chatMessageEl = this.renderMessage(chatEl, message, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message, + sender, + dt, + turnId + }); } // If no document or online context is provided, skip rendering the reference section @@ -547,7 +572,7 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean = false, willReplace: boolean = true): Element { + renderMessage({ chatBodyEl, message, sender, dt, turnId, raw = false, willReplace = true, isSystemMessage = false }: RenderMessageOptions): Element { let message_time = this.formatDate(dt ?? new Date()); // Append message to conversation history HTML element. @@ -555,7 +580,8 @@ export class KhojChatView extends KhojPaneView { let chatMessageEl = chatBodyEl.createDiv({ attr: { "data-meta": message_time, - class: `khoj-chat-message ${sender}` + class: `khoj-chat-message ${sender}`, + ...(turnId && { "data-turnId": turnId }) }, }) let chatMessageBodyEl = chatMessageEl.createDiv(); @@ -574,7 +600,7 @@ export class KhojChatView extends KhojPaneView { // Add action buttons to each chat message element if (willReplace === true) { - this.renderActionButtons(message, chatMessageBodyTextEl); + this.renderActionButtons(message, chatMessageBodyTextEl, isSystemMessage); } // Remove user-select: none property to make text selectable @@ -618,7 +644,7 @@ export class KhojChatView extends KhojPaneView { this.scrollChatToBottom(); } - renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement) { + renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement, isSystemMessage: boolean = false) { let copyButton = this.contentEl.createEl('button'); copyButton.classList.add("chat-action-button"); copyButton.title = "Copy Message to Clipboard"; @@ -632,6 +658,25 @@ export class KhojChatView extends KhojPaneView { setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); + + // Add delete button + let deleteButton = null; + if (!isSystemMessage) { + deleteButton = this.contentEl.createEl('button'); + deleteButton.classList.add("chat-action-button"); + deleteButton.title = "Delete Message"; + setIcon(deleteButton, "trash-2"); + deleteButton.addEventListener('click', () => { + const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); + if (messageEl) { + // Ask for confirmation before deleting + if (confirm('Are you sure you want to delete this message?')) { + this.deleteMessage(messageEl as HTMLElement); + } + } + }); + } + // Only enable the speech feature if the user is subscribed let speechButton = null; @@ -646,7 +691,9 @@ export class KhojChatView extends KhojPaneView { // Append buttons to parent element chatMessageBodyTextEl.append(copyButton, pasteToFile); - + if (deleteButton) { + chatMessageBodyTextEl.append(deleteButton); + } if (speechButton) { chatMessageBodyTextEl.append(speechButton); } @@ -672,7 +719,7 @@ export class KhojChatView extends KhojPaneView { if (chatInput) { chatInput.placeholder = this.startingMessage; } - this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); + this.renderMessage({chatBodyEl, message: "Hey 👋🏾, what's up?", sender: "khoj", isSystemMessage: true}); } async toggleChatSessions(forceShow: boolean = false): Promise { @@ -883,7 +930,12 @@ export class KhojChatView extends KhojPaneView { if (responseJson.detail) { // If the server returns error details in response, render a setup hint. let setupMsg = "Hi 👋🏾, to start chatting add available chat models options via [the Django Admin panel](/server/admin) on the Server"; - this.renderMessage(chatBodyEl, setupMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: setupMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } else if (responseJson.response) { @@ -897,6 +949,7 @@ export class KhojChatView extends KhojPaneView { chatBodyEl, chatLog.message, chatLog.by, + chatLog.turnId, chatLog.context, chatLog.onlineContext, new Date(chatLog.created), @@ -927,7 +980,12 @@ export class KhojChatView extends KhojPaneView { } } catch (err) { let errorMsg = "Unable to get response from Khoj server ❤️‍🩹. Ensure server is running or contact developers for help at [team@khoj.dev](mailto:team@khoj.dev) or in [Discord](https://discord.gg/BDgyabRM6e)"; - this.renderMessage(chatBodyEl, errorMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: errorMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } return true; @@ -972,7 +1030,7 @@ export class KhojChatView extends KhojPaneView { this.textToSpeech(this.chatMessageState.rawResponse); // Append any references after all the data has been streamed - this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl); + this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl, this.chatMessageState.turnId); const liveQuery = this.chatMessageState.rawQuery; // Reset variables @@ -985,6 +1043,7 @@ export class KhojChatView extends KhojPaneView { rawQuery: liveQuery, isVoice: false, generatedAssets: "", + turnId: "", }; } else if (chunk.type === "references") { this.chatMessageState.references = { "notes": chunk.data.context, "online": chunk.data.onlineContext }; @@ -1006,6 +1065,12 @@ export class KhojChatView extends KhojPaneView { this.chatMessageState.rawResponse += chunkData; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse + this.chatMessageState.generatedAssets, this.chatMessageState.loadingEllipsis); } + } else if (chunk.type === "metadata") { + const { turnId } = chunk.data; + if (turnId) { + // Append turnId to chatMessageState + this.chatMessageState.turnId = turnId; + } } } @@ -1065,7 +1130,7 @@ export class KhojChatView extends KhojPaneView { // Render user query as chat message let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; - this.renderMessage(chatBodyEl, query, "you"); + this.renderMessage({chatBodyEl, message: query, sender: "you"}); let conversationId = chatBodyEl.dataset.conversationId; if (!conversationId) { @@ -1111,6 +1176,7 @@ export class KhojChatView extends KhojPaneView { rawResponse: "", isVoice: isVoice, generatedAssets: "", + turnId: "", }; let response = await fetch(chatUrl, { @@ -1410,10 +1476,15 @@ export class KhojChatView extends KhojPaneView { return rawResponse; } - finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) { + finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null, turnId: string) { if (!!newResponseElement && references != null && Object.keys(references).length > 0) { newResponseElement.appendChild(this.createReferenceSection(references)); } + if (!!newResponseElement && turnId) { + // Set the turnId for the new response and the previous user message + newResponseElement.parentElement?.setAttribute("data-turnId", turnId); + newResponseElement.parentElement?.previousElementSibling?.setAttribute("data-turnId", turnId); + } this.scrollChatToBottom(); let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; if (chatInput) chatInput.removeAttribute("disabled"); @@ -1482,4 +1553,49 @@ export class KhojChatView extends KhojPaneView { } } } + + // Add this new method to handle message deletion + async deleteMessage(messageEl: HTMLElement) { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + const conversationId = chatBodyEl.dataset.conversationId; + + // Get the turnId from the message's data-turn attribute + const turnId = messageEl.getAttribute("data-turnId"); + if (!turnId || !conversationId) return; + + try { + const response = await fetch(`${this.setting.khojUrl}/api/chat/conversation/message`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.setting.khojApiKey}` + }, + body: JSON.stringify({ + conversation_id: conversationId, + turn_id: turnId + }) + }); + + if (response.ok) { + // Remove both the user message and Khoj response (the conversation turn) + const isKhojMessage = messageEl.classList.contains("khoj"); + const messages = Array.from(chatBodyEl.getElementsByClassName("khoj-chat-message")); + const messageIndex = messages.indexOf(messageEl); + + if (isKhojMessage && messageIndex > 0) { + // If it is a Khoj message, remove the previous user message too + messages[messageIndex - 1].remove(); + } else if (!isKhojMessage && messageIndex < messages.length - 1) { + // If it is a user message, remove the next Khoj message too + messages[messageIndex + 1].remove(); + } + messageEl.remove(); + } else { + this.flashStatusInChatInput("Failed to delete message"); + } + } catch (error) { + console.error("Error deleting message:", error); + this.flashStatusInChatInput("Error deleting message"); + } + } } diff --git a/src/interface/web/app/common/utils.ts b/src/interface/web/app/common/utils.ts index fe5043a2..55df7a35 100644 --- a/src/interface/web/app/common/utils.ts +++ b/src/interface/web/app/common/utils.ts @@ -114,3 +114,33 @@ export function useDebounce(value: T, delay: number): T { return debouncedValue; } + +export const formatDateTime = (isoString: string): string => { + try { + const date = new Date(isoString); + const now = new Date(); + const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000); + + // Show relative time for recent dates + if (diffInMinutes < 1) return "just now"; + if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`; + if (diffInMinutes < 120) return "1 hour ago"; + if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)} hours ago`; + + // For older dates, show full formatted date + const formatter = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZoneName: "short", + }); + + return formatter.format(date); + } catch (error) { + console.error("Error formatting date:", error); + return isoString; + } +}; diff --git a/src/interface/web/app/search/layout.tsx b/src/interface/web/app/search/layout.tsx index ecaa2992..cdb0976f 100644 --- a/src/interface/web/app/search/layout.tsx +++ b/src/interface/web/app/search/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "../globals.css"; import { ContentSecurityPolicy } from "../common/layoutHelper"; +import { Toaster } from "@/components/ui/toaster"; export const metadata: Metadata = { title: "Khoj AI - Search", @@ -35,7 +36,10 @@ export default function RootLayout({ return ( - {children} + + {children} + + ); } diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 90432508..6bb49cb4 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -17,23 +17,68 @@ import { ArrowLeft, ArrowRight, FileDashed, - FileMagnifyingGlass, GithubLogo, Lightbulb, LinkSimple, MagnifyingGlass, NoteBlank, NotionLogo, + Trash, + DotsThreeVertical, + Waveform, + Plus, + Download, + Brain, + Check, + BoxArrowDown, + Funnel, } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { getIconFromFilename } from "../common/iconUtils"; -import { useIsMobileWidth } from "../common/utils"; +import { formatDateTime, useIsMobileWidth } from "../common/utils"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "../components/appSidebar/appSidebar"; import { Separator } from "@/components/ui/separator"; import { KhojLogoType } from "../components/logo/khojLogo"; +import { InlineLoading } from "../components/loading/loading"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useToast } from "@/components/ui/use-toast"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +import { uploadDataForIndexing } from "../common/chatFunctions"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; interface AdditionalData { file: string; source: string; @@ -49,6 +94,12 @@ interface SearchResult { "corpus-id": string; } +interface FileObject { + file_name: string; + raw_text: string; + updated_at: string; +} + function getNoteTypeIcon(source: string) { if (source === "notion") { return ; @@ -59,24 +110,116 @@ function getNoteTypeIcon(source: string) { return ; } -const naturalLanguageSearchQueryExamples = [ - "What does the paper say about climate change?", - "Making a cappuccino at home", - "Benefits of eating mangoes", - "How to plan a wedding on a budget", - "Appointment with Dr. Makinde on 12th August", - "Class notes lecture 3 on quantum mechanics", - "Painting concepts for acrylics", - "Abstract from the paper attention is all you need", - "Climbing Everest without oxygen", - "Solving a rubik's cube in 30 seconds", - "Facts about the planet Mars", - "How to make a website using React", - "Fish at the bottom of the ocean", - "Fish farming Kenya 2021", - "How to make a cake without an oven", - "Installing a solar panel at home", -]; +interface FileCardProps { + file: FileObject; + index: number; + setSelectedFile: (file: string) => void; + setSelectedFileFullText: (file: string) => void; + handleDownload: (fileName: string, content: string) => void; + handleDelete: (fileName: string) => void; + isDeleting: boolean; + selectedFileFullText: string | null; +} + +function FileCard({ + file, + setSelectedFile, + setSelectedFileFullText, + handleDownload, + handleDelete, + isDeleting, + selectedFileFullText, +}: FileCardProps) { + return ( + + + + + +
{ + setSelectedFileFullText(""); + setSelectedFile(file.file_name); + }} + > + {file.file_name.split("/").pop()} +
+
+ + + +
+ {file.file_name.split("/").pop()} + +
+
+
+ +

+ {!selectedFileFullText && ( + + )} + {selectedFileFullText} +

+
+
+
+ + + + + + + + + + +
+
+ + +

+ {file.raw_text.slice(0, 100)}... +

+
+
+ +
+ {formatDateTime(file.updated_at)} +
+
+
+ ); +} interface NoteResultProps { note: SearchResult; @@ -89,10 +232,9 @@ function Note(props: NoteResultProps) { const fileName = isFileNameURL ? note.additional.heading : note.additional.file.split("/").pop(); - const fileIcon = getIconFromFilename(fileName || ".txt", "h-4 w-4 inline mr-2"); return ( - + {getNoteTypeIcon(note.additional.source)} @@ -110,8 +252,8 @@ function Note(props: NoteResultProps) { - - {isFileNameURL ? ( + {isFileNameURL && ( + {note.additional.file} - ) : ( -
- {fileIcon} - {note.additional.file} -
- )} -
+
+ )}
); } @@ -136,15 +273,14 @@ function focusNote(note: SearchResult) { const fileName = isFileNameURL ? note.additional.heading : note.additional.file.split("/").pop(); - const fileIcon = getIconFromFilename(fileName || ".txt", "h-4 w-4 inline mr-2"); return ( - + {fileName} - - {isFileNameURL ? ( + {isFileNameURL && ( + {note.additional.file} - ) : ( -
- {fileIcon} - {note.additional.file} -
- )} -
+
+ )}
{note.entry}
@@ -167,27 +298,363 @@ function focusNote(note: SearchResult) { ); } +const UploadFiles: React.FC<{ + setUploadedFiles: (files: string[]) => void; +}> = ({ setUploadedFiles }) => { + const [isDragAndDropping, setIsDragAndDropping] = useState(false); + + const [warning, setWarning] = useState(null); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const [progressValue, setProgressValue] = useState(0); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!uploading) { + setProgressValue(0); + if (!warning && !error) { + // Force close the dialog by simulating a click on the escape key + const event = new KeyboardEvent("keydown", { key: "Escape" }); + document.dispatchEvent(event); + } + } + + 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 handleDragOver(event: React.DragEvent) { + event.preventDefault(); + setIsDragAndDropping(true); + } + + function handleDragLeave(event: React.DragEvent) { + event.preventDefault(); + setIsDragAndDropping(false); + } + + function handleDragAndDropFiles(event: React.DragEvent) { + event.preventDefault(); + setIsDragAndDropping(false); + + if (!event.dataTransfer.files) return; + + uploadFiles(event.dataTransfer.files); + } + + function openFileInput() { + if (fileInputRef && fileInputRef.current) { + fileInputRef.current.click(); + } + } + + function handleFileChange(event: React.ChangeEvent) { + if (!event.target.files) return; + + uploadFiles(event.target.files); + } + + function uploadFiles(files: FileList) { + uploadDataForIndexing(files, setWarning, setUploading, setError, setUploadedFiles); + } + + return ( + + + + + + + Build Your Knowledge Base + + Add context for your Khoj knowledge base. Quickly search and get + personalized answers from your documents. + + +
+ +
+ {uploading && ( + + )} +
+ {warning && ( +
+
+ + {warning} +
+ +
+ )} + {error && ( +
+
+ + {error} +
+ +
+ )} +
+
+ {isDragAndDropping ? ( +
+ + Drop files to upload +
+ ) : ( +
+ + Drag and drop files here +
+ )} +
+
+
+
+
+ ); +}; + +interface FileFilterComboBoxProps { + allFiles: string[]; + onChooseFile: (file: string) => void; + isMobileWidth: boolean; + inputText?: string; + onClose: () => void; +} + +function FileFilterComboBox(props: FileFilterComboBoxProps) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(props.inputText || ""); + const [noMatchingFiles, setNoMatchingFiles] = useState(false); + const [inputText, setInputText] = useState(""); + + useEffect(() => { + if (props.inputText) { + if (props.inputText === "INITIALIZE") { + setOpen(true); + setInputText(""); + } else { + setInputText(props.inputText); + if (props.allFiles.includes(props.inputText)) { + setValue(props.inputText); + } + } + } else { + setInputText(""); + } + }, [props.inputText]); + + useEffect(() => { + if (inputText && !props.allFiles.includes(inputText)) { + setNoMatchingFiles(true); + setValue(""); + } else if (!inputText) { + setNoMatchingFiles(false); + setValue(""); + } else { + setNoMatchingFiles(false); + setValue(inputText); + } + }, [inputText, props.allFiles]); + + useEffect(() => { + if (!open) { + props.onClose(); + } + }, [open]); + + return ( + + + + + + + setInputText(e.currentTarget.value)} /> + + No files found. + + {props.allFiles.map((file) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + props.onChooseFile(currentValue); + }} + > + {file} + + + ))} + + + + + + ); +} + export default function Search() { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [searchResultsLoading, setSearchResultsLoading] = useState(false); + const searchInputRef = useRef(null); const [focusSearchResult, setFocusSearchResult] = useState(null); - const [exampleQuery, setExampleQuery] = useState(""); + const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + const [fileObjectsLoading, setFileObjectsLoading] = useState(true); + const [allFiles, setAllFiles] = useState([]); const searchTimeoutRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [selectedFileFilter, setSelectedFileFilter] = useState(undefined); + const [selectedFileFullText, setSelectedFileFullText] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [pageNumber, setPageNumber] = useState(0); + const [numPages, setNumPages] = useState(1); + + const { toast } = useToast(); const isMobileWidth = useIsMobileWidth(); useEffect(() => { - setExampleQuery( - naturalLanguageSearchQueryExamples[ - Math.floor(Math.random() * naturalLanguageSearchQueryExamples.length) - ], - ); + // Load all files once on page load + fetch("/api/content/computer", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response.json()) + .then((data) => { + setAllFiles(data); + }) + .catch((error) => { + console.error("Error loading files:", error); + }); }, []); + useEffect(() => { + // Replace the file: filter with the selected suggestion + const fileFilterMatch = searchQuery.match(/file:([^"\s]*|"[^"]*")?/); + + if (fileFilterMatch) { + const extractedFileFilter = fileFilterMatch[1]; + // Strip the double quotes + const extractedFileFilterValue = extractedFileFilter?.replace(/"/g, ""); + + if (extractedFileFilterValue) { + setSelectedFileFilter(extractedFileFilterValue); + } else { + setSelectedFileFilter("INITIALIZE"); + } + } + + }, [searchQuery]); + + function handleSearchInputChange(value: string) { + setSearchQuery(value); + + if (!value.trim()) { + setSearchResults(null); + return; + } + + // Clear previous search timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Debounce search + if (value.trim()) { + searchTimeoutRef.current = setTimeout(() => { + search(); + }, 750); + } + } + + function applySuggestion(suggestion: string) { + // Scrub any existing `file:` filter + const fileFilterRegex = /file:([^"\s]*|"[^"]*")?/i; + const searchQueryWithoutFileFilter = searchQuery.replace(fileFilterRegex, "").trim(); + + // Prepend the file: filter with the selected suggestion + const newQuery = `file:"${suggestion}" ${searchQueryWithoutFileFilter}`; + setSearchQuery(newQuery); + searchInputRef.current?.focus(); + search(); + } + function search() { if (searchResultsLoading || !searchQuery.trim()) return; + setSearchResultsLoading(true); + const apiUrl = `/api/search?q=${encodeURIComponent(searchQuery)}&client=web`; fetch(apiUrl, { method: "GET", @@ -205,29 +672,100 @@ export default function Search() { }); } - useEffect(() => { - if (!searchQuery.trim()) { - return; - } + const fetchFiles = async (currentPageNumber: number) => { + try { + const url = `api/content/files?page=${currentPageNumber}`; + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch files"); - setFocusSearchResult(null); + const filesData = await response.json(); + const filesList = filesData.files; + const totalPages = filesData.num_pages; - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } + setNumPages(totalPages); - if (searchQuery.trim()) { - searchTimeoutRef.current = setTimeout(() => { - search(); - }, 750); // 1000 milliseconds = 1 second - } - - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); + if (Array.isArray(filesList)) { + setFiles(filesList.toSorted()); } - }; - }, [searchQuery]); + } catch (error) { + setError("Failed to load files"); + console.error("Error fetching files:", error); + } finally { + setFileObjectsLoading(false); + } + }; + + useEffect(() => { + fetchFiles(pageNumber); + }, [pageNumber]); + + const fetchSpecificFile = async (fileName: string) => { + try { + const response = await fetch(`/api/content/file?file_name=${fileName}`); + if (!response.ok) throw new Error("Failed to fetch file"); + + const file = await response.json(); + setSelectedFileFullText(file.raw_text); + } catch (error) { + setError("Failed to load file"); + console.error("Error fetching file:", error); + } + }; + + const handleDownload = (fileName: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${fileName.split("/").pop()}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + useEffect(() => { + if (selectedFile) { + fetchSpecificFile(selectedFile); + } + }, [selectedFile]); + + useEffect(() => { + fetchFiles(pageNumber); + }, []); + + useEffect(() => { + if (uploadedFiles.length > 0) { + setPageNumber(0); + fetchFiles(0); + } + }, [uploadedFiles]); + + const handleDelete = async (fileName: string) => { + setIsDeleting(true); + try { + const response = await fetch(`/api/content/file?filename=${fileName}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error("Failed to delete file"); + toast({ + title: "File deleted", + description: `File ${fileName} has been deleted`, + variant: "default", + }); + + // Refresh files list + fetchFiles(pageNumber); + } catch (error) { + toast({ + title: "Error deleting file", + description: `Failed to delete file ${fileName}`, + variant: "destructive", + }); + } finally { + setIsDeleting(false); + } + }; return ( @@ -241,30 +779,62 @@ export default function Search() { ) : ( -

Search

+

Search Your Knowledge Base

)}
-
-
-
- setSearchQuery(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && search()} - type="search" - placeholder="Search Documents" - /> - +
+
+
+
+ + handleSearchInputChange(e.currentTarget.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + search(); + } + }} + ref={searchInputRef} + type="search" + placeholder="Search Documents" + value={searchQuery} + /> +
+
+ applySuggestion(file)} + isMobileWidth={isMobileWidth} + inputText={selectedFileFilter} + onClose={() => searchInputRef.current?.focus()} + /> + +
+ {searchResults === null && ( + + )} + {searchResultsLoading && ( +
+ +
+ )} {focusSearchResult && (
{searchResults.map((result, index) => { return ( @@ -297,52 +876,169 @@ export default function Search() {
)} - {searchResults == null && ( - - - - - - - Search across your documents - - - - {exampleQuery} - - - )} - {searchResults && searchResults.length === 0 && ( - - - - - - - No documents found - - - -
- To use search, upload your docs to your account. -
- -
- Learn More + {!searchResultsLoading && + searchResults === null && + !searchQuery.trim() && ( +
+ {fileObjectsLoading && ( +
+
- - - - )} + )} + {error &&
{error}
} + + {!searchResults && + !fileObjectsLoading && + files.length === 0 && ( + + + + + + + Let's get started! + + + +
+ To use search, upload docs via the + "Add Documents" button. +
+ +
+ Learn More +
+ +
+
+ )} + +
+ {files.map((file, index) => ( + + ))} +
+ + + + {/* Show prev button if not on first page */} + {pageNumber > 0 && ( + + + setPageNumber(pageNumber - 1) + } + /> + + )} + + {/* Show first page if not on first two pages */} + {pageNumber > 1 && ( + + setPageNumber(0)} + > + 1 + + + )} + + {/* Show ellipsis if there's a gap */} + {pageNumber > 2 && ( + + + + )} + + {/* Show previous page if not on first page */} + {pageNumber > 0 && ( + + + setPageNumber(pageNumber - 1) + } + > + {pageNumber} + + + )} + + {/* Current page */} + + + {pageNumber + 1} + + + + {/* Show next page if not on last page */} + {pageNumber < numPages - 1 && ( + + + setPageNumber(pageNumber + 1) + } + > + {pageNumber + 2} + + + )} + + {/* Show ellipsis if there's a gap before last page */} + {pageNumber < numPages - 3 && ( + + + + )} + + {/* Show last page if not on last two pages */} + {pageNumber < numPages - 2 && ( + + + setPageNumber(numPages - 1) + } + > + {numPages} + + + )} + + {/* Show next button if not on last page */} + {pageNumber < numPages - 1 && ( + + + setPageNumber(pageNumber + 1) + } + /> + + )} + + +
+ )}
diff --git a/src/interface/web/app/settings/page.tsx b/src/interface/web/app/settings/page.tsx index f19c53dd..9b845987 100644 --- a/src/interface/web/app/settings/page.tsx +++ b/src/interface/web/app/settings/page.tsx @@ -3,7 +3,7 @@ import styles from "./settings.module.css"; import "intl-tel-input/styles"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useToast } from "@/components/ui/use-toast"; import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates } from "../common/auth"; @@ -23,14 +23,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import { - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandDialog, -} from "@/components/ui/command"; import { ArrowRight, @@ -56,9 +48,10 @@ import { ArrowCircleUp, ArrowCircleDown, ArrowsClockwise, - Check, CaretDown, Waveform, + MagnifyingGlass, + Brain, EyeSlash, Eye, } from "@phosphor-icons/react"; @@ -66,312 +59,11 @@ import { import Loading from "../components/loading/loading"; import IntlTelInput from "intl-tel-input/react"; -import { uploadDataForIndexing } from "../common/chatFunctions"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Progress } from "@/components/ui/progress"; -import Link from "next/link"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "../components/appSidebar/appSidebar"; import { Separator } from "@/components/ui/separator"; import { KhojLogoType } from "../components/logo/khojLogo"; -const ManageFilesModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { - const [syncedFiles, setSyncedFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [isDragAndDropping, setIsDragAndDropping] = useState(false); - - const [warning, setWarning] = useState(null); - const [error, setError] = useState(null); - const [uploading, setUploading] = useState(false); - const [progressValue, setProgressValue] = useState(0); - const [uploadedFiles, setUploadedFiles] = useState([]); - const fileInputRef = useRef(null); - - 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]); - - useEffect(() => { - const fetchFiles = async () => { - try { - const response = await fetch("/api/content/computer"); - if (!response.ok) throw new Error("Failed to fetch files"); - - // Extract resonse - const syncedFiles = await response.json(); - // Validate response - if (Array.isArray(syncedFiles)) { - // Set synced files state - setSyncedFiles(syncedFiles.toSorted()); - } else { - console.error("Unexpected data format from API"); - } - } catch (error) { - console.error("Error fetching files:", error); - } - }; - - fetchFiles(); - }, [uploadedFiles]); - - const filteredFiles = syncedFiles.filter((file) => - file.toLowerCase().includes(searchQuery.toLowerCase()), - ); - - const deleteSelected = async () => { - let filesToDelete = selectedFiles.length > 0 ? selectedFiles : filteredFiles; - - if (filesToDelete.length === 0) { - return; - } - - try { - const response = await fetch("/api/content/files", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ files: filesToDelete }), - }); - - if (!response.ok) throw new Error("Failed to delete files"); - - // Update the syncedFiles state - setSyncedFiles((prevFiles) => - prevFiles.filter((file) => !filesToDelete.includes(file)), - ); - - // Reset selectedFiles - setSelectedFiles([]); - } catch (error) { - console.error("Error deleting files:", error); - } - }; - - const deleteFile = async (filename: string) => { - try { - const response = await fetch( - `/api/content/file?filename=${encodeURIComponent(filename)}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - }, - ); - - if (!response.ok) throw new Error("Failed to delete file"); - - // Update the syncedFiles state - setSyncedFiles((prevFiles) => prevFiles.filter((file) => file !== filename)); - - // Remove the file from selectedFiles if it's there - setSelectedFiles((prevSelected) => prevSelected.filter((file) => file !== filename)); - } catch (error) { - console.error("Error deleting file:", error); - } - }; - - function handleDragOver(event: React.DragEvent) { - event.preventDefault(); - setIsDragAndDropping(true); - } - - function handleDragLeave(event: React.DragEvent) { - event.preventDefault(); - setIsDragAndDropping(false); - } - - function handleDragAndDropFiles(event: React.DragEvent) { - event.preventDefault(); - setIsDragAndDropping(false); - - if (!event.dataTransfer.files) return; - - uploadFiles(event.dataTransfer.files); - } - - function openFileInput() { - if (fileInputRef && fileInputRef.current) { - fileInputRef.current.click(); - } - } - - function handleFileChange(event: React.ChangeEvent) { - if (!event.target.files) return; - - uploadFiles(event.target.files); - } - - function uploadFiles(files: FileList) { - uploadDataForIndexing(files, setWarning, setUploading, setError, setUploadedFiles); - } - - return ( - - - - - Alert - - {warning || error} - { - setWarning(null); - setError(null); - setUploading(false); - }} - > - Close - - - -
- -
- Upload files - {uploading && ( - - )} -
-
-
- {isDragAndDropping ? ( -
- - Drop files to upload -
- ) : ( -
- - Drag and drop files here -
- )} -
-
-
-
-
- -
-
- - - {syncedFiles.length === 0 ? ( -
- - No files synced -
- ) : ( -
- Could not find a good match. - - Need advanced search? Click here. - -
- )} -
- - {filteredFiles.map((filename: string) => ( - { - setSelectedFiles((prev) => - prev.includes(value) - ? prev.filter((f) => f !== value) - : [...prev, value], - ); - }} - > -
-
- {selectedFiles.includes(filename) && ( - - )} - {filename} -
- -
-
- ))} -
-
-
- -
-
- -
-
-
-
- ); -}; - interface DropdownComponentProps { items: ModelOptions[]; selected: number; @@ -604,7 +296,6 @@ export default function SettingsView() { const [numberValidationState, setNumberValidationState] = useState( PhoneNumberValidationState.Verified, ); - const [isManageFilesModalOpen, setIsManageFilesModalOpen] = useState(false); const { toast } = useToast(); const isMobileWidth = useIsMobileWidth(); @@ -1162,18 +853,13 @@ export default function SettingsView() {
- {isManageFilesModalOpen && ( - setIsManageFilesModalOpen(false)} - /> - )}
Content
- - - Files + + + Knowledge Base {userConfig.enabled_content_source.computer && ( - Manage your synced files + Manage and search through your digital brain. diff --git a/src/interface/web/components/ui/pagination.tsx b/src/interface/web/components/ui/pagination.tsx new file mode 100644 index 00000000..c5c99bea --- /dev/null +++ b/src/interface/web/components/ui/pagination.tsx @@ -0,0 +1,118 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +