diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx index c3d5ff37..966cdd17 100644 --- a/src/interface/web/app/chat/page.tsx +++ b/src/interface/web/app/chat/page.tsx @@ -8,7 +8,7 @@ import ChatHistory from "../components/chatHistory/chatHistory"; import { useSearchParams } from "next/navigation"; import Loading from "../components/loading/loading"; -import { processMessageChunk } from "../common/chatFunctions"; +import { generateNewTitle, processMessageChunk } from "../common/chatFunctions"; import "katex/dist/katex.min.css"; @@ -19,7 +19,11 @@ import { StreamMessage, } from "../components/chatMessage/chatMessage"; import { useIPLocationData, useIsMobileWidth, welcomeConsole } from "../common/utils"; -import { ChatInputArea, ChatOptions } from "../components/chatInputArea/chatInputArea"; +import { + AttachedFileText, + ChatInputArea, + ChatOptions, +} from "../components/chatInputArea/chatInputArea"; import { useAuthenticatedData } from "../common/auth"; import { AgentData } from "../agents/page"; @@ -30,7 +34,7 @@ interface ChatBodyDataProps { setQueryToProcess: (query: string) => void; streamedMessages: StreamMessage[]; setStreamedMessages: (messages: StreamMessage[]) => void; - setUploadedFiles: (files: string[]) => void; + setUploadedFiles: (files: AttachedFileText[] | undefined) => void; isMobileWidth?: boolean; isLoggedIn: boolean; setImages: (images: string[]) => void; @@ -77,7 +81,24 @@ function ChatBodyData(props: ChatBodyDataProps) { setIsInResearchMode(true); } } - }, [setQueryToProcess, props.setImages]); + + const storedUploadedFiles = localStorage.getItem("uploadedFiles"); + + if (storedUploadedFiles) { + const parsedFiles = storedUploadedFiles ? JSON.parse(storedUploadedFiles) : []; + const uploadedFiles: AttachedFileText[] = []; + for (const file of parsedFiles) { + uploadedFiles.push({ + name: file.name, + file_type: file.file_type, + content: file.content, + size: file.size, + }); + } + localStorage.removeItem("uploadedFiles"); + props.setUploadedFiles(uploadedFiles); + } + }, [setQueryToProcess, props.setImages, conversationId]); useEffect(() => { if (message) { @@ -100,6 +121,7 @@ function ChatBodyData(props: ChatBodyDataProps) { ) { setProcessingMessage(false); setImages([]); // Reset images after processing + props.setUploadedFiles(undefined); // Reset uploaded files after processing } else { setMessage(""); } @@ -153,7 +175,7 @@ export default function Chat() { const [messages, setMessages] = useState([]); const [queryToProcess, setQueryToProcess] = useState(""); const [processQuerySignal, setProcessQuerySignal] = useState(false); - const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadedFiles, setUploadedFiles] = useState(undefined); const [images, setImages] = useState([]); const locationData = useIPLocationData() || { @@ -192,6 +214,7 @@ export default function Chat() { timestamp: new Date().toISOString(), rawQuery: queryToProcess || "", images: images, + queryFiles: uploadedFiles, }; setMessages((prevMessages) => [...prevMessages, newStreamMessage]); setProcessQuerySignal(true); @@ -224,6 +247,9 @@ export default function Chat() { setQueryToProcess(""); setProcessQuerySignal(false); setImages([]); + + if (conversationId) generateNewTitle(conversationId, setTitle); + break; } @@ -273,6 +299,7 @@ export default function Chat() { timezone: locationData.timezone, }), ...(images.length > 0 && { images: images }), + ...(uploadedFiles && { files: uploadedFiles }), }; const response = await fetch(chatAPI, { @@ -325,7 +352,7 @@ export default function Chat() {
diff --git a/src/interface/web/app/common/chatFunctions.ts b/src/interface/web/app/common/chatFunctions.ts index a42dde40..b4777ba8 100644 --- a/src/interface/web/app/common/chatFunctions.ts +++ b/src/interface/web/app/common/chatFunctions.ts @@ -267,6 +267,78 @@ export async function createNewConversation(slug: string) { } } +export async function packageFilesForUpload(files: FileList): Promise { + const formData = new FormData(); + + const fileReadPromises = Array.from(files).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 if (fileExtension === "docx") { + fileType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } 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); + }); + }); + + await Promise.all(fileReadPromises); + return formData; +} + +export function generateNewTitle(conversationId: string, setTitle: (title: string) => void) { + fetch(`/api/chat/title?conversation_id=${conversationId}`, { + method: "POST", + }) + .then((res) => { + if (!res.ok) throw new Error(`Failed to call API with error ${res.statusText}`); + return res.json(); + }) + .then((data) => { + setTitle(data.title); + }) + .catch((err) => { + console.error(err); + return; + }); +} + export function uploadDataForIndexing( files: FileList, setWarning: (warning: string) => void, diff --git a/src/interface/web/app/common/iconUtils.tsx b/src/interface/web/app/common/iconUtils.tsx index b6391d1b..f266cac5 100644 --- a/src/interface/web/app/common/iconUtils.tsx +++ b/src/interface/web/app/common/iconUtils.tsx @@ -49,8 +49,11 @@ import { Gavel, Broadcast, KeyReturn, + FilePdf, + FileMd, + MicrosoftWordLogo, } from "@phosphor-icons/react"; -import { Markdown, OrgMode, Pdf, Word } from "@/app/components/logo/fileLogo"; +import { OrgMode } from "@/app/components/logo/fileLogo"; interface IconMap { [key: string]: (color: string, width: string, height: string) => JSX.Element | null; @@ -238,11 +241,12 @@ function getIconFromFilename( return ; case "markdown": case "md": - return ; + return ; case "pdf": - return ; + return ; case "doc": - return ; + case "docx": + return ; case "jpg": case "jpeg": case "png": diff --git a/src/interface/web/app/common/utils.ts b/src/interface/web/app/common/utils.ts index 6c10ba8a..efa2c1d3 100644 --- a/src/interface/web/app/common/utils.ts +++ b/src/interface/web/app/common/utils.ts @@ -71,6 +71,16 @@ export function useIsMobileWidth() { return isMobileWidth; } +export const convertBytesToText = (fileSize: number) => { + if (fileSize < 1024) { + return `${fileSize} B`; + } else if (fileSize < 1024 * 1024) { + return `${(fileSize / 1024).toFixed(2)} KB`; + } else { + return `${(fileSize / (1024 * 1024)).toFixed(2)} MB`; + } +}; + export function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); diff --git a/src/interface/web/app/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx index 772f1d35..ea566df4 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.tsx +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -373,6 +373,7 @@ export default function ChatHistory(props: ChatHistoryProps) { images: message.images, conversationId: props.conversationId, turnId: messageTurnId, + queryFiles: message.queryFiles, }} customClassName="fullHistory" borderLeftColor={`${data?.agent?.color}-500`} diff --git a/src/interface/web/app/components/chatInputArea/chatInputArea.tsx b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx index a036ccb3..88a4fcae 100644 --- a/src/interface/web/app/components/chatInputArea/chatInputArea.tsx +++ b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx @@ -40,19 +40,36 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { convertColorToTextClass, convertToBGClass } from "@/app/common/colorUtils"; import LoginPrompt from "../loginPrompt/loginPrompt"; -import { uploadDataForIndexing } from "../../common/chatFunctions"; import { InlineLoading } from "../loading/loading"; -import { getIconForSlashCommand } from "@/app/common/iconUtils"; +import { getIconForSlashCommand, getIconFromFilename } from "@/app/common/iconUtils"; +import { packageFilesForUpload } from "@/app/common/chatFunctions"; +import { convertBytesToText } from "@/app/common/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; export interface ChatOptions { [key: string]: string; } +export interface AttachedFileText { + name: string; + content: string; + file_type: string; + size: number; +} + interface ChatInputProps { sendMessage: (message: string) => void; sendImage: (image: string) => void; sendDisabled: boolean; - setUploadedFiles?: (files: string[]) => void; + setUploadedFiles: (files: AttachedFileText[]) => void; conversationId?: string | null; chatOptionsData?: ChatOptions | null; isMobileWidth?: boolean; @@ -75,6 +92,9 @@ export const ChatInputArea = forwardRef((pr const [imagePaths, setImagePaths] = useState([]); const [imageData, setImageData] = useState([]); + const [attachedFiles, setAttachedFiles] = useState(null); + const [convertedAttachedFiles, setConvertedAttachedFiles] = useState([]); + const [recording, setRecording] = useState(false); const [mediaRecorder, setMediaRecorder] = useState(null); @@ -154,6 +174,8 @@ export const ChatInputArea = forwardRef((pr } props.sendMessage(messageToSend); + setAttachedFiles(null); + setConvertedAttachedFiles([]); setMessage(""); } @@ -203,22 +225,69 @@ export const ChatInputArea = forwardRef((pr setImagePaths((prevPaths) => [...prevPaths, ...newImagePaths]); // Set focus to the input for user message after uploading files chatInputRef?.current?.focus(); - return; } - uploadDataForIndexing( - files, - setWarning, - setUploading, - setError, - props.setUploadedFiles, - props.conversationId, + // Process all non-image files + const nonImageFiles = Array.from(files).filter( + (file) => !image_endings.includes(file.name.split(".").pop() || ""), ); + // Concatenate attachedFiles and files + const newFiles = nonImageFiles + ? Array.from(nonImageFiles).concat(Array.from(attachedFiles || [])) + : Array.from(attachedFiles || []); + + // Ensure files are below size limit (10 MB) + for (let i = 0; i < newFiles.length; i++) { + if (newFiles[i].size > 10 * 1024 * 1024) { + setWarning( + `File ${newFiles[i].name} is too large. Please upload files smaller than 10 MB.`, + ); + return; + } + } + + const dataTransfer = new DataTransfer(); + newFiles.forEach((file) => dataTransfer.items.add(file)); + setAttachedFiles(dataTransfer.files); + + // Extract text from files + extractTextFromFiles(dataTransfer.files).then((data) => { + props.setUploadedFiles(data); + setConvertedAttachedFiles(data); + }); + // Set focus to the input for user message after uploading files chatInputRef?.current?.focus(); } + async function extractTextFromFiles(files: FileList): Promise { + const formData = await packageFilesForUpload(files); + setUploading(true); + + try { + const response = await fetch("/api/content/convert", { + method: "POST", + body: formData, + }); + setUploading(false); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + setError( + "Error converting files. " + + error + + ". Please try again, or contact team@khoj.dev if the issue persists.", + ); + console.error("Error converting files:", error); + return []; + } + } + // Assuming this function is added within the same context as the provided excerpt async function startRecordingAndTranscribe() { try { @@ -445,6 +514,93 @@ export const ChatInputArea = forwardRef((pr )}
+
+ {imageUploaded && + imagePaths.map((path, index) => ( +
+ {`img-${index}`} + +
+ ))} + {convertedAttachedFiles && + Array.from(convertedAttachedFiles).map((file, index) => ( + + +
+
+
+ + {file.name} + + + {getIconFromFilename(file.file_type)} + + {convertBytesToText(file.size)} + + +
+
+ +
+
+ + + {file.name} + + + + {file.content} + + + +
+ ))} +
((pr > -
+ +
-
- {imageUploaded && - imagePaths.map((path, index) => ( -
- {`img-${index}`} - -
- ))} -