import styles from "./chatInputArea.module.css"; import React, { useEffect, useRef, useState, forwardRef } from "react"; import DOMPurify from "dompurify"; import "katex/dist/katex.min.css"; import { ArrowUp, Microphone, Paperclip, X, Stop, ToggleLeft, ToggleRight, } from "@phosphor-icons/react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Popover, PopoverContent } from "@/components/ui/popover"; import { PopoverTrigger } from "@radix-ui/react-popover"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { convertColorToTextClass, convertToBGClass } from "@/app/common/colorUtils"; import LoginPrompt from "../loginPrompt/loginPrompt"; import { InlineLoading } from "../loading/loading"; 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: AttachedFileText[]) => void; conversationId?: string | null; chatOptionsData?: ChatOptions | null; isMobileWidth?: boolean; isLoggedIn: boolean; agentColor?: string; isResearchModeEnabled?: boolean; } export const ChatInputArea = forwardRef((props, ref) => { const [message, setMessage] = useState(""); const fileInputRef = useRef(null); const [warning, setWarning] = useState(null); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const [loginRedirectMessage, setLoginRedirectMessage] = useState(null); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [imageUploaded, setImageUploaded] = useState(false); 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); const [progressValue, setProgressValue] = useState(0); const [isDragAndDropping, setIsDragAndDropping] = useState(false); const [showCommandList, setShowCommandList] = useState(false); const [useResearchMode, setUseResearchMode] = useState( props.isResearchModeEnabled || false, ); const chatInputRef = ref as React.MutableRefObject; 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(() => { async function fetchImageData() { if (imagePaths.length > 0) { const newImageData = await Promise.all( imagePaths.map(async (path) => { const response = await fetch(path); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); }), ); setImageData(newImageData); } setUploading(false); } setUploading(true); fetchImageData(); }, [imagePaths]); useEffect(() => { if (props.isResearchModeEnabled) { setUseResearchMode(props.isResearchModeEnabled); } }, [props.isResearchModeEnabled]); function onSendMessage() { if (imageUploaded) { setImageUploaded(false); setImagePaths([]); imageData.forEach((data) => props.sendImage(data)); } if (!message.trim()) return; if (!props.isLoggedIn) { setLoginRedirectMessage( "Hey there, you need to be signed in to send messages to Khoj AI", ); setShowLoginPrompt(true); return; } let messageToSend = message.trim(); if (useResearchMode && !messageToSend.startsWith("/research")) { messageToSend = `/research ${messageToSend}`; } props.sendMessage(messageToSend); setAttachedFiles(null); setConvertedAttachedFiles([]); setMessage(""); } function handleSlashCommandClick(command: string) { setMessage(`/${command} `); } function handleFileButtonClick() { if (!fileInputRef.current) return; fileInputRef.current.click(); } function handleFileChange(event: React.ChangeEvent) { if (!event.target.files) return; uploadFiles(event.target.files); } function handleDragAndDropFiles(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(false); if (!event.dataTransfer.files) return; uploadFiles(event.dataTransfer.files); } function uploadFiles(files: FileList) { if (!props.isLoggedIn) { setLoginRedirectMessage("Please login to chat with your files"); setShowLoginPrompt(true); return; } // check for image files const image_endings = ["jpg", "jpeg", "png", "webp"]; const newImagePaths: string[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const file_extension = file.name.split(".").pop(); if (image_endings.includes(file_extension || "")) { newImagePaths.push(DOMPurify.sanitize(URL.createObjectURL(file))); } } if (newImagePaths.length > 0) { setImageUploaded(true); setImagePaths((prevPaths) => [...prevPaths, ...newImagePaths]); // Set focus to the input for user message after uploading files chatInputRef?.current?.focus(); } // 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 || []); if (newFiles.length > 0) { // 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)); // Extract text from files extractTextFromFiles(dataTransfer.files).then((data) => { props.setUploadedFiles(data); setAttachedFiles(dataTransfer.files); 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 { const microphone = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(microphone, { mimeType: "audio/webm" }); const audioChunks: Blob[] = []; mediaRecorder.ondataavailable = async (event) => { audioChunks.push(event.data); const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); const formData = new FormData(); formData.append("file", audioBlob); // Send the incremental audio blob to the server try { const response = await fetch("/api/transcribe", { method: "POST", body: formData, }); if (!response.ok) { throw new Error("Network response was not ok"); } const transcription = await response.json(); setMessage(transcription.text.trim()); } catch (error) { console.error("Error sending audio to server:", error); } }; // Send an audio blob every 1.5 seconds mediaRecorder.start(1500); mediaRecorder.onstop = async () => { const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); const formData = new FormData(); formData.append("file", audioBlob); // Send the audio blob to the server try { const response = await fetch("/api/transcribe", { method: "POST", body: formData, }); if (!response.ok) { throw new Error("Network response was not ok"); } const transcription = await response.json(); mediaRecorder.stream.getTracks().forEach((track) => track.stop()); setMediaRecorder(null); setMessage(transcription.text.trim()); } catch (error) { console.error("Error sending audio to server:", error); } }; setMediaRecorder(mediaRecorder); } catch (error) { console.error("Error getting microphone", error); } } useEffect(() => { if (!recording && mediaRecorder) { mediaRecorder.stop(); } if (recording && !mediaRecorder) { startRecordingAndTranscribe(); } }, [recording, mediaRecorder]); useEffect(() => { if (!chatInputRef?.current) return; chatInputRef.current.style.height = "auto"; chatInputRef.current.style.height = Math.max(chatInputRef.current.scrollHeight - 24, 64) + "px"; if (message.startsWith("/") && message.split(" ").length === 1) { setShowCommandList(true); } else { setShowCommandList(false); } }, [message]); function handleDragOver(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(true); } function handleDragLeave(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(false); } function removeImageUpload(index: number) { setImagePaths((prevPaths) => prevPaths.filter((_, i) => i !== index)); setImageData((prevData) => prevData.filter((_, i) => i !== index)); if (imagePaths.length === 1) { setImageUploaded(false); } } return ( <> {showLoginPrompt && loginRedirectMessage && ( )} {uploading && ( Uploading data. Please wait. setUploading(false)} > Dismiss )} {warning && ( Data Upload Warning {warning} setWarning(null)} > Close )} {error && ( Oh no! Something went wrong while uploading your data {error} setError(null)} > Close )} {showCommandList && (
e.preventDefault()} className={`${props.isMobileWidth ? "w-[100vw]" : "w-full"} rounded-md`} side="bottom" align="center" /* Offset below text area on home page (i.e where conversationId is unset) */ sideOffset={props.conversationId ? 0 : 80} alignOffset={0} > No matching commands. {props.chatOptionsData && Object.entries(props.chatOptionsData).map( ([key, value]) => ( handleSlashCommandClick(key) } >
{getIconForSlashCommand( key, "h-4 w-4 mr-2", )} /{key}
{value}
), )}
)}
{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}
))}