'use client' import TextareaAutosize from 'react-textarea-autosize' import { cn, toGigabytes } from '@/lib/utils' import { usePrompt } from '@/hooks/usePrompt' import { useThreads } from '@/hooks/useThreads' import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { ArrowRight } from 'lucide-react' import { IconPaperclip, IconWorld, IconAtom, IconEye, IconTool, IconCodeCircle2, IconPlayerStopFilled, IconBrandSpeedtest, IconX, } from '@tabler/icons-react' import { useTranslation } from 'react-i18next' import { useGeneralSetting } from '@/hooks/useGeneralSetting' import { useModelProvider } from '@/hooks/useModelProvider' import { useAppState } from '@/hooks/useAppState' import { MovingBorder } from './MovingBorder' import { useChat } from '@/hooks/useChat' import DropdownModelProvider from '@/containers/DropdownModelProvider' import { ModelLoader } from '@/containers/loaders/ModelLoader' import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable' import { getConnectedServers } from '@/services/mcp' import { stopAllModels } from '@/services/models' import { useOutOfContextPromiseModal } from './dialogs/OutOfContextDialog' type ChatInputProps = { className?: string showSpeedToken?: boolean model?: ThreadModel initialMessage?: boolean } const ChatInput = ({ model, className, showSpeedToken = true, initialMessage, }: ChatInputProps) => { const textareaRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const [rows, setRows] = useState(1) const { streamingContent, abortControllers, loadingModel, tools } = useAppState() const { prompt, setPrompt } = usePrompt() const { currentThreadId } = useThreads() const { t } = useTranslation() const { spellCheckChatInput } = useGeneralSetting() const { tokenSpeed } = useAppState() const { showModal, PromiseModal: OutOfContextModal } = useOutOfContextPromiseModal() const maxRows = 10 const { selectedModel } = useModelProvider() const { sendMessage } = useChat() const [message, setMessage] = useState('') const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false) const [tooltipToolsAvailable, setTooltipToolsAvailable] = useState(false) const [uploadedFiles, setUploadedFiles] = useState< Array<{ name: string type: string size: number base64: string dataUrl: string }> >([]) const [connectedServers, setConnectedServers] = useState([]) // Check for connected MCP servers useEffect(() => { const checkConnectedServers = async () => { try { const servers = await getConnectedServers() setConnectedServers(servers) } catch (error) { console.error('Failed to get connected servers:', error) setConnectedServers([]) } } checkConnectedServers() // Poll for connected servers every 3 seconds const intervalId = setInterval(checkConnectedServers, 3000) return () => clearInterval(intervalId) }, []) // Check if there are active MCP servers const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 const handleSendMesage = (prompt: string) => { if (!selectedModel) { setMessage('Please select a model to start chatting.') return } if (!prompt.trim()) { return } setMessage('') sendMessage(prompt, showModal) } useEffect(() => { const handleFocusIn = () => { if (document.activeElement === textareaRef.current) { setIsFocused(true) } } const handleFocusOut = () => { if (document.activeElement !== textareaRef.current) { setIsFocused(false) } } document.addEventListener('focusin', handleFocusIn) document.addEventListener('focusout', handleFocusOut) return () => { document.removeEventListener('focusin', handleFocusIn) document.removeEventListener('focusout', handleFocusOut) } }, []) // Focus when component mounts useEffect(() => { if (textareaRef.current) { textareaRef.current.focus() } }, []) useEffect(() => { if (tooltipToolsAvailable && dropdownToolsAvailable) { setTooltipToolsAvailable(false) } }, [dropdownToolsAvailable, tooltipToolsAvailable]) // Focus when thread changes useEffect(() => { if (textareaRef.current) { textareaRef.current.focus() } }, [currentThreadId]) // Focus when streaming content finishes useEffect(() => { if (!streamingContent && textareaRef.current) { // Small delay to ensure UI has updated setTimeout(() => { textareaRef.current?.focus() }, 10) } }, [streamingContent]) const stopStreaming = useCallback( (threadId: string) => { abortControllers[threadId]?.abort() stopAllModels() }, [abortControllers] ) const fileInputRef = useRef(null) const handleAttachmentClick = () => { fileInputRef.current?.click() } const handleRemoveFile = (indexToRemove: number) => { setUploadedFiles((prev) => prev.filter((_, index) => index !== indexToRemove) ) } const getFileTypeFromExtension = (fileName: string): string => { const extension = fileName.toLowerCase().split('.').pop() switch (extension) { case 'jpg': case 'jpeg': return 'image/jpeg' case 'png': return 'image/png' case 'pdf': return 'application/pdf' default: return '' } } const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files if (files && files.length > 0) { const maxSize = 10 * 1024 * 1024 // 10MB in bytes const newFiles: Array<{ name: string type: string size: number base64: string dataUrl: string }> = [] Array.from(files).forEach((file) => { // Check file size if (file.size > maxSize) { setMessage(`File is too large. Maximum size is 10MB.`) // Reset file input to allow re-uploading if (fileInputRef.current) { fileInputRef.current.value = '' } return } // Get file type - use extension as fallback if MIME type is incorrect const detectedType = file.type || getFileTypeFromExtension(file.name) const actualType = getFileTypeFromExtension(file.name) || detectedType // Check file type const allowedTypes = [ 'image/jpg', 'image/jpeg', 'image/png', 'application/pdf', ] if (!allowedTypes.includes(actualType)) { setMessage( `File is not supported. Only JPEG, JPG, PNG, and PDF files are allowed.` ) // Reset file input to allow re-uploading if (fileInputRef.current) { fileInputRef.current.value = '' } return } const reader = new FileReader() reader.onload = () => { const result = reader.result if (typeof result === 'string') { const base64String = result.split(',')[1] const fileData = { name: file.name, size: file.size, type: actualType, base64: base64String, dataUrl: result, } newFiles.push(fileData) // Update state if ( newFiles.length === Array.from(files).filter((f) => { const fType = getFileTypeFromExtension(f.name) || f.type return f.size <= maxSize && allowedTypes.includes(fType) }).length ) { setUploadedFiles((prev) => { const updated = [...prev, ...newFiles] return updated }) // Reset the file input value to allow re-uploading the same file if (fileInputRef.current) { fileInputRef.current.value = '' setMessage('') } } } } reader.readAsDataURL(file) }) } if (textareaRef.current) { textareaRef.current.focus() } } return (
{streamingContent && (
)}
{uploadedFiles.length > 0 && (
{uploadedFiles.map((file, index) => { return (
{file.type.startsWith('image/') && ( {`${file.name} )} {file.type === 'application/pdf' && (
{file.name.split('.').pop()}
{file.name}

{toGigabytes(file.size)}

)}
handleRemoveFile(index)} >
) })}
)} { setPrompt(e.target.value) // Count the number of newlines to estimate rows const newRows = (e.target.value.match(/\n/g) || []).length + 1 setRows(Math.min(newRows, maxRows)) }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && prompt.trim()) { e.preventDefault() // Submit the message when Enter is pressed without Shift handleSendMesage(prompt) // When Shift+Enter is pressed, a new line is added (default behavior) } }} placeholder={t('common.placeholder.chatInput')} autoFocus spellCheck={spellCheckChatInput} data-gramm={spellCheckChatInput} data-gramm_editor={spellCheckChatInput} data-gramm_grammarly={spellCheckChatInput} className={cn( 'bg-transparent pt-4 w-full flex-shrink-0 border-none resize-none outline-0 px-4', rows < maxRows && 'scrollbar-hide', className )} />
{model?.provider === 'llama.cpp' && loadingModel ? ( ) : ( )} {/* File attachment - always available */}
{/* Microphone - always available - Temp Hide */} {/*
*/} {selectedModel?.capabilities?.includes('vision') && (

Vision

)} {selectedModel?.capabilities?.includes('embeddings') && (

Embeddings

)} {selectedModel?.capabilities?.includes('tools') && hasActiveMCPServers && (
{ setDropdownToolsAvailable(false) e.stopPropagation() }} > { setDropdownToolsAvailable(isOpen) setTooltipToolsAvailable(false) }} > {(isOpen, toolsCount) => { return (
{toolsCount > 0 && (
{toolsCount > 99 ? '99+' : toolsCount}
)}
) }}

Tools

)} {selectedModel?.capabilities?.includes('web_search') && (

Web Search

)} {selectedModel?.capabilities?.includes('reasoning') && (

Reasoning

)}
{showSpeedToken && (
{Math.round(tokenSpeed?.tokenSpeed ?? 0)} tokens/sec
)}
{streamingContent ? ( ) : ( )}
{message && (
{message} { setMessage('') // Reset file input to allow re-uploading the same file if (fileInputRef.current) { fileInputRef.current.value = '' } }} />
)}
) } export default ChatInput