'use client' import TextareaAutosize from 'react-textarea-autosize' import { cn } 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 { IconPhoto, IconWorld, IconAtom, IconTool, IconCodeCircle2, IconPlayerStopFilled, IconX, IconPaperclip, IconLoader2, } 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 { useServiceHub } from '@/hooks/useServiceHub' import { useTools } from '@/hooks/useTools' import { TokenCounter } from '@/components/TokenCounter' import { useMessages } from '@/hooks/useMessages' import { useShallow } from 'zustand/react/shallow' import { McpExtensionToolLoader } from './McpExtensionToolLoader' import { ExtensionTypeEnum, MCPExtension, RAGExtension } from '@janhq/core' import { ExtensionManager } from '@/lib/extension' import { useAttachments } from '@/hooks/useAttachments' import { open } from '@tauri-apps/plugin-dialog' import { toast } from 'sonner' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' type ChatInputProps = { className?: string showSpeedToken?: boolean model?: ThreadModel initialMessage?: boolean projectId?: string } const ChatInput = ({ model, className, initialMessage, projectId, }: ChatInputProps) => { const textareaRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const [rows, setRows] = useState(1) const serviceHub = useServiceHub() const streamingContent = useAppState((state) => state.streamingContent) const abortControllers = useAppState((state) => state.abortControllers) const loadingModel = useAppState((state) => state.loadingModel) const tools = useAppState((state) => state.tools) const cancelToolCall = useAppState((state) => state.cancelToolCall) const prompt = usePrompt((state) => state.prompt) const setPrompt = usePrompt((state) => state.setPrompt) const currentThreadId = useThreads((state) => state.currentThreadId) const { t } = useTranslation() const spellCheckChatInput = useGeneralSetting( (state) => state.spellCheckChatInput ) const tokenCounterCompact = useGeneralSetting( (state) => state.tokenCounterCompact ) useTools() // Get current thread messages for token counting const threadMessages = useMessages( useShallow((state) => currentThreadId ? state.messages[currentThreadId] : [] ) ) const maxRows = 10 const selectedModel = useModelProvider((state) => state.selectedModel) const selectedProvider = useModelProvider((state) => state.selectedProvider) 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 }> >([]) // Document attachments (desktop RAG ingestion). We only index on send. const [docFiles, setDocFiles] = useState< Array<{ name: string path: string size?: number type?: string }> >([]) const [connectedServers, setConnectedServers] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [hasMmproj, setHasMmproj] = useState(false) const [hasActiveModels, setHasActiveModels] = useState(false) const attachmentsEnabled = useAttachments((s) => s.enabled) const [ingestingDocs, setIngestingDocs] = useState(false) // Determine whether to show the Attach documents button (simple gating) const showAttachmentButton = attachmentsEnabled && PlatformFeatures[PlatformFeature.ATTACHMENTS] // Check for connected MCP servers useEffect(() => { const checkConnectedServers = async () => { try { const servers = await serviceHub.mcp().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) }, [serviceHub]) // Check for active models useEffect(() => { const checkActiveModels = async () => { try { const activeModels = await serviceHub .models() .getActiveModels('llamacpp') setHasActiveModels(activeModels.length > 0) } catch (error) { console.error('Failed to get active models:', error) setHasActiveModels(false) } } checkActiveModels() // Poll for active models every 3 seconds const intervalId = setInterval(checkActiveModels, 3000) return () => clearInterval(intervalId) }, [serviceHub]) // Check for mmproj existence or vision capability when model changes useEffect(() => { const checkMmprojSupport = async () => { if (selectedModel && selectedModel?.id) { try { // Only check mmproj for llamacpp provider if (selectedModel?.capabilities?.includes('vision')) { setHasMmproj(true) } else { setHasMmproj(false) } } catch (error) { console.error('Error checking mmproj:', error) setHasMmproj(false) } } } checkMmprojSupport() }, [selectedModel, selectedModel?.capabilities, selectedProvider, serviceHub]) // Check if there are active MCP servers const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 // Get MCP extension and its custom component const extensionManager = ExtensionManager.getInstance() const mcpExtension = extensionManager.get(ExtensionTypeEnum.MCP) const MCPToolComponent = mcpExtension?.getToolComponent?.() const handleSendMesage = async (prompt: string) => { if (!selectedModel) { setMessage('Please select a model to start chatting.') return } if (!prompt.trim() && uploadedFiles.length === 0 && docFiles.length === 0) { return } // If we have pending doc files, index them first if (docFiles.length > 0) { try { setIngestingDocs(true) const rag = extensionManager.get(ExtensionTypeEnum.RAG) if (!rag?.ingestAttachments) throw new Error('Retrieval extension not available') for (const f of docFiles) { const id = (toast as any).loading ? (toast as any).loading(`Indexing ${f.name || f.path}…`) : undefined try { await rag.ingestAttachments(currentThreadId!, [{ path: f.path, name: f.name }]) if (id) toast.success(`Indexed ${f.name || f.path}`, { id }) } catch (err) { if (id) toast.error(`Failed to index ${f.name || f.path}`, { id }) throw err } } setDocFiles([]) } catch (err) { const desc = err instanceof Error ? err.message : String(err) toast.error('Failed to index attachments', { description: desc }) setIngestingDocs(false) return } setIngestingDocs(false) } setMessage('') sendMessage( prompt, true, uploadedFiles.length > 0 ? uploadedFiles : undefined, projectId ) 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() cancelToolCall?.() }, [abortControllers, cancelToolCall] ) const fileInputRef = useRef(null) const handleAttachmentClick = () => { fileInputRef.current?.click() } const handleAttachDocsIngest = async () => { try { if (!attachmentsEnabled) { toast.info('Attachments are disabled in Settings') return } if (!currentThreadId) { toast.info('Please start a thread first to attach documents.') return } const selection = await open({ multiple: true, filters: [ { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md', 'csv', 'xlsx', 'xls', 'ods', 'pptx', 'html', 'htm'], }, ], }) if (!selection) return const paths = Array.isArray(selection) ? selection : [selection] if (!paths.length) return setDocFiles((prev) => [ ...prev, ...paths.map((p) => ({ path: p, name: p.split(/[\\/]/).pop() || p, })), ]) } catch (e) { console.error('Failed to ingest attachments:', e) const desc = e instanceof Error ? e.message : String(e) toast.error('Failed to attach documents', { description: desc }) } } 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' 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 - images only const allowedTypes = ['image/jpg', 'image/jpeg', 'image/png'] if (!allowedTypes.includes(actualType)) { setMessage( `File attachments not supported currently. Only JPEG, JPG, and PNG 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() // Only allow drag if model supports mmproj if (hasMmproj) { 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 if (hasMmproj) { setIsDragOver(true) } } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragOver(false) // Only allow drop if model supports mmproj if (!hasMmproj) { return } // 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) } } const handlePaste = async (e: React.ClipboardEvent) => { // Only process images if model supports mmproj if (hasMmproj) { const clipboardItems = e.clipboardData?.items let hasProcessedImage = false // Try clipboardData.items first (traditional method) if (clipboardItems && clipboardItems.length > 0) { const imageItems = Array.from(clipboardItems).filter((item) => item.type.startsWith('image/') ) if (imageItems.length > 0) { e.preventDefault() const files: File[] = [] let processedCount = 0 imageItems.forEach((item) => { const file = item.getAsFile() if (file) { files.push(file) } processedCount++ // When all items are processed, handle the valid files if (processedCount === imageItems.length) { if (files.length > 0) { const syntheticEvent = { target: { files: files, }, } as unknown as React.ChangeEvent handleFileChange(syntheticEvent) hasProcessedImage = true } } }) // If we found image items but couldn't get files, fall through to modern API if (processedCount === imageItems.length && !hasProcessedImage) { // Continue to modern clipboard API fallback below } else { return // Successfully processed with traditional method } } } // Modern Clipboard API fallback (for Linux, images copied from web, etc.) if ( navigator.clipboard && 'read' in navigator.clipboard && !hasProcessedImage ) { try { const clipboardContents = await navigator.clipboard.read() const files: File[] = [] for (const item of clipboardContents) { const imageTypes = item.types.filter((type) => type.startsWith('image/') ) for (const type of imageTypes) { try { const blob = await item.getType(type) // Convert blob to File with better naming const extension = type.split('/')[1] || 'png' const file = new File( [blob], `pasted-image-${Date.now()}.${extension}`, { type } ) files.push(file) } catch (error) { console.error('Error reading clipboard item:', error) } } } if (files.length > 0) { e.preventDefault() const syntheticEvent = { target: { files: files, }, } as unknown as React.ChangeEvent handleFileChange(syntheticEvent) return } } catch (error) { console.error('Clipboard API access failed:', error) } } // If we reach here, no image was found - allow normal text pasting to continue console.log( 'No image data found in clipboard, allowing normal text paste' ) } // If hasMmproj is false or no images found, allow normal text pasting to continue } return (
{streamingContent && (
)}
{(uploadedFiles.length > 0 || docFiles.length > 0) && (
{uploadedFiles.map((file, index) => { return (
{file.type.startsWith('image/') && ( {`${file.name} )}
handleRemoveFile(index)} >
) })} {docFiles.map((file, index) => (
{file.name}
setDocFiles((prev) => prev.filter((_, i) => i !== 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) } }} onPaste={handlePaste} 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 ? ( ) : ( )} {/* Vision image attachment - show only for models with mmproj */} {hasMmproj && (

{t('vision')}

)} {/* RAG document attachments - desktop-only via dialog; shown when feature enabled */} {selectedModel?.capabilities?.includes('tools') && showAttachmentButton && (
{ingestingDocs ? ( ) : ( )}

{ingestingDocs ? 'Indexing documents…' : 'Attach documents'}

)} {/* Microphone - always available - Temp Hide */} {/*
*/} {selectedModel?.capabilities?.includes('embeddings') && (

{t('embeddings')}

)} {selectedModel?.capabilities?.includes('tools') && hasActiveMCPServers && (MCPToolComponent ? ( // Use custom MCP component ) : ( // Use default tools dropdown
{ 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')}

)}
{selectedProvider === 'llamacpp' && hasActiveModels && tokenCounterCompact && !initialMessage && (threadMessages?.length > 0 || prompt.trim().length > 0) && (
)} {streamingContent ? ( ) : ( )}
{message && (
{message} { setMessage('') // Reset file input to allow re-uploading the same file if (fileInputRef.current) { fileInputRef.current.value = '' } }} />
)} {selectedProvider === 'llamacpp' && hasActiveModels && !tokenCounterCompact && !initialMessage && (threadMessages?.length > 0 || prompt.trim().length > 0) && (
)}
) } export default ChatInput