'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, IconX, } from '@tabler/icons-react' import { useTranslation } from '@/i18n/react-i18next-compat' 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' type ChatInputProps = { className?: string showSpeedToken?: boolean model?: ThreadModel initialMessage?: boolean } const ChatInput = ({ model, className, 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, experimentalFeatures } = useGeneralSetting() 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([]) const [isDragOver, setIsDragOver] = useState(false) // 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() && uploadedFiles.length === 0) { return } setMessage('') sendMessage( prompt, true, uploadedFiles.length > 0 ? uploadedFiles : undefined ) setUploadedFiles([]) } 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() }, [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 - exclude PDF for local models (llamacpp) const allowedTypes = [ 'image/jpg', 'image/jpeg', 'image/png', ...(model?.provider !== 'llamacpp' ? ['application/pdf'] : []), ] if (!allowedTypes.includes(actualType)) { const supportedFormats = model?.provider === 'llamacpp' ? 'JPEG, JPG, and PNG' : 'JPEG, JPG, PNG, and PDF' setMessage( `File is not supported. Only ${supportedFormats} 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() } } const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // Only set dragOver to false if we're leaving the drop zone entirely // In Tauri, relatedTarget can be null, so we need to handle that case const relatedTarget = e.relatedTarget as Node | null if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) { setIsDragOver(false) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // Ensure drag state is maintained during drag over setIsDragOver(true) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(false) // Check if dataTransfer exists (it might not in some Tauri scenarios) if (!e.dataTransfer) { console.warn('No dataTransfer available in drop event') return } const files = e.dataTransfer.files if (files && files.length > 0) { // Create a synthetic event to reuse existing file handling logic const syntheticEvent = { target: { files: files, }, } as React.ChangeEvent handleFileChange(syntheticEvent) } } 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) => { // e.keyCode 229 is for IME input with Safari const isComposing = e.nativeEvent.isComposing || e.keyCode === 229 if ( e.key === 'Enter' && !e.shiftKey && prompt.trim() && !isComposing ) { 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 === 'llamacpp' && loadingModel ? ( ) : ( )} {/* File attachment - always available */}
{/* Microphone - always available - Temp Hide */} {/*
*/} {selectedModel?.capabilities?.includes('vision') && (

{t('vision')}

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

{t('embeddings')}

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

{t('tools')}

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

Web Search

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

{t('reasoning')}

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