* fix: mcp tool error handling * fix: error message * fix: trigger download from recommend model * fix: can't scroll hub * fix: show progress * ✨enhancement: prompt users to increase context size * ✨enhancement: rearrange action buttons for a better UX * 🔧chore: clean up logics --------- Co-authored-by: Faisal Amir <urmauur@gmail.com>
623 lines
22 KiB
TypeScript
623 lines
22 KiB
TypeScript
'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<HTMLTextAreaElement>(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<string[]>([])
|
|
|
|
// 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<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<div
|
|
className={cn(
|
|
'relative overflow-hidden p-[2px] rounded-lg',
|
|
Boolean(streamingContent) && 'opacity-70'
|
|
)}
|
|
>
|
|
{streamingContent && (
|
|
<div className="absolute inset-0">
|
|
<MovingBorder rx="10%" ry="10%">
|
|
<div
|
|
className={cn(
|
|
'h-100 w-100 bg-[radial-gradient(var(--app-primary),transparent_60%)]'
|
|
)}
|
|
/>
|
|
</MovingBorder>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'relative z-20 px-0 pb-10 border border-main-view-fg/5 rounded-lg text-main-view-fg bg-main-view',
|
|
isFocused && 'ring-1 ring-main-view-fg/10'
|
|
)}
|
|
>
|
|
{uploadedFiles.length > 0 && (
|
|
<div className="flex gap-3 items-center p-2 pb-0">
|
|
{uploadedFiles.map((file, index) => {
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
'relative border border-main-view-fg/5 rounded-lg',
|
|
file.type.startsWith('image/') ? 'size-14' : 'h-14 '
|
|
)}
|
|
>
|
|
{file.type.startsWith('image/') && (
|
|
<img
|
|
className="object-cover w-full h-full rounded-lg"
|
|
src={file.dataUrl}
|
|
alt={`${file.name} - ${index}`}
|
|
/>
|
|
)}
|
|
{file.type === 'application/pdf' && (
|
|
<div className="bg-main-view-fg/4 h-full rounded-lg p-2 max-w-[400px] pr-4">
|
|
<div className="flex gap-2 items-center justify-center h-full">
|
|
<div className="size-10 rounded-md bg-main-view shrink-0 flex items-center justify-center">
|
|
<span className="uppercase font-bold">
|
|
{file.name.split('.').pop()}
|
|
</span>
|
|
</div>
|
|
<div className="truncate">
|
|
<h6 className="truncate mb-0.5 text-main-view-fg/80">
|
|
{file.name}
|
|
</h6>
|
|
<p className="text-xs text-main-view-fg/70">
|
|
{toGigabytes(file.size)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
|
onClick={() => handleRemoveFile(index)}
|
|
>
|
|
<IconX className="text-destructive-fg" size={16} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<TextareaAutosize
|
|
ref={textareaRef}
|
|
disabled={Boolean(streamingContent)}
|
|
minRows={2}
|
|
rows={1}
|
|
maxRows={10}
|
|
value={prompt}
|
|
onChange={(e) => {
|
|
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
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute z-20 bg-transparent bottom-0 w-full p-2 ">
|
|
<div className="flex justify-between items-center w-full">
|
|
<div className="px-1 flex items-center gap-1">
|
|
<div
|
|
className={cn(
|
|
'px-1 flex items-center gap-1',
|
|
streamingContent && 'opacity-50 pointer-events-none'
|
|
)}
|
|
>
|
|
{model?.provider === 'llama.cpp' && loadingModel ? (
|
|
<ModelLoader />
|
|
) : (
|
|
<DropdownModelProvider
|
|
model={model}
|
|
useLastUsedModel={initialMessage}
|
|
/>
|
|
)}
|
|
{/* File attachment - always available */}
|
|
<div
|
|
className="h-6 hidden p-1 items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1"
|
|
onClick={handleAttachmentClick}
|
|
>
|
|
<IconPaperclip size={18} className="text-main-view-fg/50" />
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
/>
|
|
</div>
|
|
{/* Microphone - always available - Temp Hide */}
|
|
{/* <div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
|
<IconMicrophone size={18} className="text-main-view-fg/50" />
|
|
</div> */}
|
|
{selectedModel?.capabilities?.includes('vision') && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger disabled={dropdownToolsAvailable}>
|
|
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
|
<IconEye size={18} className="text-main-view-fg/50" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Vision</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{selectedModel?.capabilities?.includes('embeddings') && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
|
<IconCodeCircle2
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Embeddings</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
|
|
{selectedModel?.capabilities?.includes('tools') &&
|
|
hasActiveMCPServers && (
|
|
<TooltipProvider>
|
|
<Tooltip
|
|
open={tooltipToolsAvailable}
|
|
onOpenChange={setTooltipToolsAvailable}
|
|
>
|
|
<TooltipTrigger
|
|
asChild
|
|
disabled={dropdownToolsAvailable}
|
|
>
|
|
<div
|
|
onClick={(e) => {
|
|
setDropdownToolsAvailable(false)
|
|
e.stopPropagation()
|
|
}}
|
|
>
|
|
<DropdownToolsAvailable
|
|
initialMessage={initialMessage}
|
|
onOpenChange={(isOpen) => {
|
|
setDropdownToolsAvailable(isOpen)
|
|
setTooltipToolsAvailable(false)
|
|
}}
|
|
>
|
|
{(isOpen, toolsCount) => {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1 cursor-pointer relative',
|
|
isOpen && 'bg-main-view-fg/10'
|
|
)}
|
|
>
|
|
<IconTool
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
{toolsCount > 0 && (
|
|
<div className="absolute -top-2 -right-2 bg-accent text-accent-fg text-xs rounded-full size-5 flex items-center justify-center font-medium">
|
|
<span className="leading-0 text-xs">
|
|
{toolsCount > 99 ? '99+' : toolsCount}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}}
|
|
</DropdownToolsAvailable>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Tools</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{selectedModel?.capabilities?.includes('web_search') && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
|
<IconWorld
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Web Search</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{selectedModel?.capabilities?.includes('reasoning') && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
|
<IconAtom
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Reasoning</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
|
|
{showSpeedToken && (
|
|
<div className="flex items-center gap-1 text-main-view-fg/60 text-xs">
|
|
<IconBrandSpeedtest size={18} />
|
|
<span>
|
|
{Math.round(tokenSpeed?.tokenSpeed ?? 0)} tokens/sec
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{streamingContent ? (
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={() =>
|
|
stopStreaming(currentThreadId ?? streamingContent.thread_id)
|
|
}
|
|
>
|
|
<IconPlayerStopFilled />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant={!prompt.trim() ? null : 'default'}
|
|
size="icon"
|
|
disabled={!prompt.trim()}
|
|
onClick={() => handleSendMesage(prompt)}
|
|
>
|
|
{streamingContent ? (
|
|
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
|
) : (
|
|
<ArrowRight className="text-primary-fg" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{message && (
|
|
<div className="bg-main-view-fg/2 -mt-0.5 mx-2 pb-2 px-3 pt-1.5 rounded-b-lg text-xs text-destructive transition-all duration-200 ease-in-out">
|
|
<div className="flex items-center gap-1 justify-between">
|
|
{message}
|
|
<IconX
|
|
className="size-3 text-main-view-fg/30 cursor-pointer"
|
|
onClick={() => {
|
|
setMessage('')
|
|
// Reset file input to allow re-uploading the same file
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ''
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<OutOfContextModal />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ChatInput
|