jan/web-app/src/containers/ChatInput.tsx
2025-10-08 16:08:40 +07:00

1042 lines
36 KiB
TypeScript

'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<HTMLTextAreaElement>(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<string[]>([])
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<MCPExtension>(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<RAGExtension>(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<HTMLInputElement>(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<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 - 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<HTMLInputElement>
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<HTMLInputElement>
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<HTMLInputElement>
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 (
<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',
isDragOver && 'ring-2 ring-accent border-accent'
)}
data-drop-zone={hasMmproj ? 'true' : undefined}
onDragEnter={hasMmproj ? handleDragEnter : undefined}
onDragLeave={hasMmproj ? handleDragLeave : undefined}
onDragOver={hasMmproj ? handleDragOver : undefined}
onDrop={hasMmproj ? handleDrop : undefined}
>
{(uploadedFiles.length > 0 || docFiles.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}`}
/>
)}
<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>
)
})}
{docFiles.map((file, index) => (
<div
key={`doc-${index}`}
className="relative border border-main-view-fg/5 rounded-lg px-2 py-1 text-xs flex items-center gap-2 bg-main-view/40"
>
<IconPaperclip size={14} className="text-main-view-fg/50" />
<span className="max-w-48 truncate" title={file.name}>
{file.name}
</span>
<div
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
onClick={() =>
setDocFiles((prev) =>
prev.filter((_, i) => i !== 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}
data-testid={'chat-input'}
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) => {
// 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
)}
/>
</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 flex-1 min-w-0">
<div
className={cn(
'px-1 flex items-center w-full',
streamingContent && 'opacity-50 pointer-events-none'
)}
>
{model?.provider === 'llamacpp' && loadingModel ? (
<ModelLoader />
) : (
<DropdownModelProvider
model={model}
useLastUsedModel={initialMessage}
/>
)}
{/* Vision image attachment - show only for models with mmproj */}
{hasMmproj && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1"
onClick={handleAttachmentClick}
>
<IconPhoto
size={18}
className="text-main-view-fg/50"
/>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileChange}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('vision')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* RAG document attachments - desktop-only via dialog; shown when feature enabled */}
{selectedModel?.capabilities?.includes('tools') &&
showAttachmentButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={handleAttachDocsIngest}
className="h-7 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"
>
{ingestingDocs ? (
<IconLoader2
size={18}
className="text-main-view-fg/50 animate-spin"
/>
) : (
<IconPaperclip
size={18}
className="text-main-view-fg/50"
/>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{ingestingDocs
? 'Indexing documents…'
: 'Attach documents'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Microphone - always available - Temp Hide */}
{/* <div className="h-7 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('embeddings') && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="h-7 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>{t('embeddings')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{selectedModel?.capabilities?.includes('tools') &&
hasActiveMCPServers &&
(MCPToolComponent ? (
// Use custom MCP component
<McpExtensionToolLoader
tools={tools}
hasActiveMCPServers={hasActiveMCPServers}
selectedModelHasTools={
selectedModel?.capabilities?.includes('tools') ?? false
}
initialMessage={initialMessage}
MCPToolComponent={MCPToolComponent}
/>
) : (
// Use default tools dropdown
<TooltipProvider>
<Tooltip
open={tooltipToolsAvailable}
onOpenChange={setTooltipToolsAvailable}
>
<TooltipTrigger
asChild
disabled={dropdownToolsAvailable}
>
<div
onClick={(e) => {
setDropdownToolsAvailable(false)
e.stopPropagation()
}}
>
<DropdownToolsAvailable
initialMessage={initialMessage}
onOpenChange={(isOpen) => {
setDropdownToolsAvailable(isOpen)
if (isOpen) {
setTooltipToolsAvailable(false)
}
}}
>
{(isOpen, toolsCount) => {
return (
<div
className={cn(
'h-7 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>{t('tools')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
{selectedModel?.capabilities?.includes('web_search') && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="h-7 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-7 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>{t('reasoning')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
<div className="flex items-center gap-2">
{selectedProvider === 'llamacpp' &&
hasActiveModels &&
tokenCounterCompact &&
!initialMessage &&
(threadMessages?.length > 0 || prompt.trim().length > 0) && (
<div className="flex-1 flex justify-center">
<TokenCounter
messages={threadMessages || []}
compact={true}
uploadedFiles={uploadedFiles}
/>
</div>
)}
{streamingContent ? (
<Button
variant="destructive"
size="icon"
onClick={() =>
stopStreaming(currentThreadId ?? streamingContent.thread_id)
}
>
<IconPlayerStopFilled />
</Button>
) : (
<Button
variant={
!prompt.trim() &&
uploadedFiles.length === 0 &&
docFiles.length === 0
? null
: 'default'
}
size="icon"
disabled={
(!prompt.trim() &&
uploadedFiles.length === 0 &&
docFiles.length === 0) ||
ingestingDocs
}
data-test-id="send-message-button"
onClick={() => handleSendMesage(prompt)}
>
{streamingContent || ingestingDocs ? (
<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>
</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>
)}
{selectedProvider === 'llamacpp' &&
hasActiveModels &&
!tokenCounterCompact &&
!initialMessage &&
(threadMessages?.length > 0 || prompt.trim().length > 0) && (
<div className="flex-1 w-full flex justify-start px-2">
<TokenCounter
messages={threadMessages || []}
compact={false}
uploadedFiles={uploadedFiles}
/>
</div>
)}
</div>
)
}
export default ChatInput