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 { uploadDataForIndexing } from "../../common/chatFunctions"; import { InlineLoading } from "../loading/loading"; import { getIconForSlashCommand } from "@/app/common/iconUtils"; export interface ChatOptions { [key: string]: string; } interface ChatInputProps { sendMessage: (message: string) => void; sendImage: (image: string) => void; sendDisabled: boolean; setUploadedFiles?: (files: string[]) => 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 [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 = `/research ${messageToSend}`; } props.sendMessage(messageToSend); 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(); return; } uploadDataForIndexing( files, setWarning, setUploading, setError, props.setUploadedFiles, props.conversationId, ); // Set focus to the input for user message after uploading files chatInputRef?.current?.focus(); } // 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="top" 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}`}
))}