| import Textarea from '@/components/ui/Textarea' |
| import Input from '@/components/ui/Input' |
| import Button from '@/components/ui/Button' |
| import { useCallback, useEffect, useRef, useState } from 'react' |
| import { throttle } from '@/lib/utils' |
| import { queryText, queryTextStream } from '@/api/lightrag' |
| import { errorMessage } from '@/lib/utils' |
| import { useSettingsStore } from '@/stores/settings' |
| import { useDebounce } from '@/hooks/useDebounce' |
| import QuerySettings from '@/components/retrieval/QuerySettings' |
| import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage' |
| import { EraserIcon, SendIcon, CopyIcon } from 'lucide-react' |
| import { useTranslation } from 'react-i18next' |
| import { toast } from 'sonner' |
| import { copyToClipboard } from '@/utils/clipboard' |
| import type { QueryMode } from '@/api/lightrag' |
|
|
| |
| const generateUniqueId = () => { |
| |
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { |
| return crypto.randomUUID(); |
| } |
| |
| return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; |
| }; |
|
|
| |
| const detectLatexCompleteness = (content: string): boolean => { |
| |
| const blockLatexMatches = content.match(/\$\$/g) || [] |
| const hasUnclosedBlock = blockLatexMatches.length % 2 !== 0 |
|
|
| |
| |
| const contentWithoutBlocks = content.replace(/\$\$[\s\S]*?\$\$/g, '') |
| const inlineLatexMatches = contentWithoutBlocks.match(/(?<!\$)\$(?!\$)/g) || [] |
| const hasUnclosedInline = inlineLatexMatches.length % 2 !== 0 |
|
|
| |
| return !hasUnclosedBlock && !hasUnclosedInline |
| } |
|
|
| |
| const parseCOTContent = (content: string) => { |
| const thinkStartTag = '<think>' |
| const thinkEndTag = '</think>' |
|
|
| |
| const startMatches: number[] = [] |
| const endMatches: number[] = [] |
|
|
| let startIndex = 0 |
| while ((startIndex = content.indexOf(thinkStartTag, startIndex)) !== -1) { |
| startMatches.push(startIndex) |
| startIndex += thinkStartTag.length |
| } |
|
|
| let endIndex = 0 |
| while ((endIndex = content.indexOf(thinkEndTag, endIndex)) !== -1) { |
| endMatches.push(endIndex) |
| endIndex += thinkEndTag.length |
| } |
|
|
| |
| const hasThinkStart = startMatches.length > 0 |
| const hasThinkEnd = endMatches.length > 0 |
| const isThinking = hasThinkStart && (startMatches.length > endMatches.length) |
|
|
| let thinkingContent = '' |
| let displayContent = content |
|
|
| if (hasThinkStart) { |
| if (hasThinkEnd && startMatches.length === endMatches.length) { |
| |
| const lastStartIndex = startMatches[startMatches.length - 1] |
| const lastEndIndex = endMatches[endMatches.length - 1] |
|
|
| if (lastEndIndex > lastStartIndex) { |
| thinkingContent = content.substring( |
| lastStartIndex + thinkStartTag.length, |
| lastEndIndex |
| ).trim() |
|
|
| |
| displayContent = content.substring(lastEndIndex + thinkEndTag.length).trim() |
| } |
| } else if (isThinking) { |
| |
| const lastStartIndex = startMatches[startMatches.length - 1] |
| thinkingContent = content.substring(lastStartIndex + thinkStartTag.length) |
| displayContent = '' |
| } |
| } |
|
|
| return { |
| isThinking, |
| thinkingContent, |
| displayContent, |
| hasValidThinkBlock: hasThinkStart && hasThinkEnd && startMatches.length === endMatches.length |
| } |
| } |
|
|
| export default function RetrievalTesting() { |
| const { t } = useTranslation() |
| |
| const currentTab = useSettingsStore.use.currentTab() |
| const isRetrievalTabActive = currentTab === 'retrieval' |
|
|
| const [messages, setMessages] = useState<MessageWithError[]>(() => { |
| try { |
| const history = useSettingsStore.getState().retrievalHistory || [] |
| |
| return history.map((msg, index) => { |
| try { |
| const msgWithError = msg as MessageWithError |
| return { |
| ...msg, |
| id: msgWithError.id || `hist-${Date.now()}-${index}`, |
| mermaidRendered: msgWithError.mermaidRendered ?? true, |
| latexRendered: msgWithError.latexRendered ?? true |
| } |
| } catch (error) { |
| console.error('Error processing message:', error) |
| |
| return { |
| role: 'system', |
| content: 'Error loading message', |
| id: `error-${Date.now()}-${index}`, |
| isError: true, |
| mermaidRendered: true |
| } |
| } |
| }) |
| } catch (error) { |
| console.error('Error loading history:', error) |
| return [] |
| } |
| }) |
| const [inputValue, setInputValue] = useState('') |
| const [isLoading, setIsLoading] = useState(false) |
| const [inputError, setInputError] = useState('') |
| const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null) |
|
|
| |
| const hasMultipleLines = inputValue.includes('\n') |
|
|
| |
| const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { |
| setInputValue(e.target.value) |
| if (inputError) setInputError('') |
| }, [inputError]) |
|
|
| |
| const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => { |
| requestAnimationFrame(() => { |
| element.style.height = 'auto' |
| element.style.height = Math.min(element.scrollHeight, 120) + 'px' |
| }) |
| }, []) |
|
|
| |
| const scrollToBottom = useCallback(() => { |
| |
| programmaticScrollRef.current = true |
| |
| requestAnimationFrame(() => { |
| if (messagesEndRef.current) { |
| |
| messagesEndRef.current.scrollIntoView({ behavior: 'auto' }) |
| } |
| }) |
| }, []) |
|
|
| const handleSubmit = useCallback( |
| async (e: React.FormEvent) => { |
| e.preventDefault() |
| if (!inputValue.trim() || isLoading) return |
|
|
| |
| const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass'] |
| const prefixMatch = inputValue.match(/^\/(\w+)\s+([\s\S]+)/) |
| let modeOverride: QueryMode | undefined = undefined |
| let actualQuery = inputValue |
|
|
| |
| if (/^\/\S+/.test(inputValue) && !prefixMatch) { |
| setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid')) |
| return |
| } |
|
|
| if (prefixMatch) { |
| const mode = prefixMatch[1] as QueryMode |
| const query = prefixMatch[2] |
| if (!allowedModes.includes(mode)) { |
| setInputError( |
| t('retrievePanel.retrieval.queryModeError', { |
| modes: 'naive, local, global, hybrid, mix, bypass', |
| }) |
| ) |
| return |
| } |
| modeOverride = mode |
| actualQuery = query |
| } |
|
|
| |
| setInputError('') |
|
|
| |
| thinkingStartTime.current = null |
| thinkingProcessed.current = false |
|
|
| |
| |
| const userMessage: MessageWithError = { |
| id: generateUniqueId(), |
| content: inputValue, |
| role: 'user' |
| } |
|
|
| const assistantMessage: MessageWithError = { |
| id: generateUniqueId(), |
| content: '', |
| role: 'assistant', |
| mermaidRendered: false, |
| latexRendered: false, |
| thinkingTime: null, |
| thinkingContent: undefined, |
| displayContent: undefined, |
| isThinking: false |
| } |
|
|
| const prevMessages = [...messages] |
|
|
| |
| setMessages([...prevMessages, userMessage, assistantMessage]) |
|
|
| |
| shouldFollowScrollRef.current = true |
| |
| isReceivingResponseRef.current = true |
|
|
| |
| setTimeout(() => { |
| scrollToBottom() |
| }, 0) |
|
|
| |
| setInputValue('') |
| setIsLoading(true) |
|
|
| |
| if (inputRef.current) { |
| if ('style' in inputRef.current) { |
| inputRef.current.style.height = '40px' |
| } |
| } |
|
|
| |
| const updateAssistantMessage = (chunk: string, isError?: boolean) => { |
| assistantMessage.content += chunk |
|
|
| |
| if (assistantMessage.content.includes('<think>') && !thinkingStartTime.current) { |
| thinkingStartTime.current = Date.now() |
| } |
|
|
| |
| const cotResult = parseCOTContent(assistantMessage.content) |
|
|
| |
| assistantMessage.isThinking = cotResult.isThinking |
|
|
| |
| if (cotResult.hasValidThinkBlock && !thinkingProcessed.current) { |
| if (thinkingStartTime.current && !assistantMessage.thinkingTime) { |
| const duration = (Date.now() - thinkingStartTime.current) / 1000 |
| assistantMessage.thinkingTime = parseFloat(duration.toFixed(2)) |
| } |
| thinkingProcessed.current = true |
| } |
|
|
| |
| assistantMessage.thinkingContent = cotResult.thinkingContent |
| |
| if (cotResult.isThinking) { |
| assistantMessage.displayContent = '' |
| } else { |
| assistantMessage.displayContent = cotResult.displayContent || assistantMessage.content |
| } |
|
|
| |
| |
| const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g |
| let mermaidRendered = false |
| let match |
| while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) { |
| |
| if (match[1] && match[1].trim().length > 10) { |
| mermaidRendered = true |
| break |
| } |
| } |
| assistantMessage.mermaidRendered = mermaidRendered |
|
|
| |
| const latexRendered = detectLatexCompleteness(assistantMessage.content) |
| assistantMessage.latexRendered = latexRendered |
|
|
| |
| setMessages((prev) => { |
| const newMessages = [...prev] |
| const lastMessage = newMessages[newMessages.length - 1] |
| if (lastMessage && lastMessage.id === assistantMessage.id) { |
| |
| Object.assign(lastMessage, { |
| content: assistantMessage.content, |
| thinkingContent: assistantMessage.thinkingContent, |
| displayContent: assistantMessage.displayContent, |
| isThinking: assistantMessage.isThinking, |
| isError: isError, |
| mermaidRendered: assistantMessage.mermaidRendered, |
| latexRendered: assistantMessage.latexRendered, |
| thinkingTime: assistantMessage.thinkingTime |
| }) |
| } |
| return newMessages |
| }) |
|
|
| |
| |
| if (shouldFollowScrollRef.current) { |
| setTimeout(() => { |
| scrollToBottom() |
| }, 30) |
| } |
| } |
|
|
| |
| const state = useSettingsStore.getState() |
|
|
| |
| if (state.querySettings.user_prompt && state.querySettings.user_prompt.trim()) { |
| state.addUserPromptToHistory(state.querySettings.user_prompt.trim()) |
| } |
|
|
| |
| const effectiveMode = modeOverride || state.querySettings.mode |
|
|
| |
| const configuredHistoryTurns = state.querySettings.history_turns || 0 |
| const effectiveHistoryTurns = (effectiveMode === 'bypass' && configuredHistoryTurns === 0) |
| ? 3 |
| : configuredHistoryTurns |
|
|
| const queryParams = { |
| ...state.querySettings, |
| query: actualQuery, |
| response_type: 'Multiple Paragraphs', |
| conversation_history: effectiveHistoryTurns > 0 |
| ? prevMessages |
| .filter((m) => m.isError !== true) |
| .slice(-effectiveHistoryTurns * 2) |
| .map((m) => ({ role: m.role, content: m.content })) |
| : [], |
| ...(modeOverride ? { mode: modeOverride } : {}) |
| } |
|
|
| try { |
| |
| if (state.querySettings.stream) { |
| let errorMessage = '' |
| await queryTextStream(queryParams, updateAssistantMessage, (error) => { |
| errorMessage += error |
| }) |
| if (errorMessage) { |
| if (assistantMessage.content) { |
| errorMessage = assistantMessage.content + '\n' + errorMessage |
| } |
| updateAssistantMessage(errorMessage, true) |
| } |
| } else { |
| const response = await queryText(queryParams) |
| updateAssistantMessage(response.response) |
| } |
| } catch (err) { |
| |
| updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true) |
| } finally { |
| |
| setIsLoading(false) |
| isReceivingResponseRef.current = false |
|
|
| |
| try { |
| |
| const finalCotResult = parseCOTContent(assistantMessage.content) |
|
|
| |
| assistantMessage.isThinking = false |
|
|
| |
| if (finalCotResult.hasValidThinkBlock && thinkingStartTime.current && !assistantMessage.thinkingTime) { |
| const duration = (Date.now() - thinkingStartTime.current) / 1000 |
| assistantMessage.thinkingTime = parseFloat(duration.toFixed(2)) |
| } |
|
|
| |
| if (finalCotResult.displayContent !== undefined) { |
| assistantMessage.displayContent = finalCotResult.displayContent |
| } |
|
|
| } catch (error) { |
| console.error('Error in final COT state validation:', error) |
| |
| assistantMessage.isThinking = false |
| } finally { |
| |
| thinkingStartTime.current = null |
| } |
|
|
| |
| try { |
| useSettingsStore |
| .getState() |
| .setRetrievalHistory([...prevMessages, userMessage, assistantMessage]) |
| } catch (error) { |
| console.error('Error saving retrieval history:', error) |
| } |
| } |
| }, |
| [inputValue, isLoading, messages, setMessages, t, scrollToBottom] |
| ) |
|
|
| const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => { |
| if (e.key === 'Enter' && e.shiftKey) { |
| |
| e.preventDefault() |
| const target = e.target as HTMLInputElement | HTMLTextAreaElement |
| const start = target.selectionStart || 0 |
| const end = target.selectionEnd || 0 |
| const newValue = inputValue.slice(0, start) + '\n' + inputValue.slice(end) |
| setInputValue(newValue) |
|
|
| |
| setTimeout(() => { |
| if (target.setSelectionRange) { |
| target.setSelectionRange(start + 1, start + 1) |
| } |
|
|
| |
| if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') { |
| adjustTextareaHeight(inputRef.current as HTMLTextAreaElement) |
| } |
| }, 0) |
| } else if (e.key === 'Enter' && !e.shiftKey) { |
| |
| e.preventDefault() |
| handleSubmit(e as any) |
| } |
| }, [inputValue, handleSubmit, adjustTextareaHeight]) |
|
|
| const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => { |
| |
| const pastedText = e.clipboardData.getData('text') |
|
|
| |
| if (pastedText.includes('\n')) { |
| e.preventDefault() |
|
|
| |
| const target = e.target as HTMLInputElement | HTMLTextAreaElement |
| const start = target.selectionStart || 0 |
| const end = target.selectionEnd || 0 |
|
|
| |
| const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end) |
|
|
| |
| setInputValue(newValue) |
|
|
| |
| setTimeout(() => { |
| if (inputRef.current && inputRef.current.setSelectionRange) { |
| const newCursorPosition = start + pastedText.length |
| inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition) |
| } |
| }, 0) |
| } |
| |
| }, [inputValue]) |
|
|
| |
| useEffect(() => { |
| if (inputRef.current) { |
| |
| const currentElement = inputRef.current |
| const cursorPosition = currentElement.selectionStart || inputValue.length |
|
|
| |
| requestAnimationFrame(() => { |
| currentElement.focus() |
| if (currentElement.setSelectionRange) { |
| currentElement.setSelectionRange(cursorPosition, cursorPosition) |
| } |
| }) |
| } |
| }, [hasMultipleLines, inputValue.length]) |
|
|
| |
| useEffect(() => { |
| if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') { |
| adjustTextareaHeight(inputRef.current as HTMLTextAreaElement) |
| } |
| }, [hasMultipleLines, inputValue, adjustTextareaHeight]) |
|
|
| |
| const shouldFollowScrollRef = useRef(true) |
| const thinkingStartTime = useRef<number | null>(null) |
| const thinkingProcessed = useRef(false) |
| |
| const isFormInteractionRef = useRef(false) |
| |
| const programmaticScrollRef = useRef(false) |
| |
| const isReceivingResponseRef = useRef(false) |
| const messagesEndRef = useRef<HTMLDivElement>(null) |
| const messagesContainerRef = useRef<HTMLDivElement>(null) |
|
|
| |
| useEffect(() => { |
| |
| return () => { |
| if (thinkingStartTime.current) { |
| thinkingStartTime.current = null; |
| } |
| }; |
| }, []); |
|
|
| |
| useEffect(() => { |
| const container = messagesContainerRef.current; |
| if (!container) return; |
|
|
| |
| const handleWheel = (e: WheelEvent) => { |
| |
| if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) { |
| shouldFollowScrollRef.current = false; |
| } |
| }; |
|
|
| |
| |
| const handleScroll = throttle(() => { |
| |
| if (programmaticScrollRef.current) { |
| programmaticScrollRef.current = false; |
| return; |
| } |
|
|
| |
| const container = messagesContainerRef.current; |
| if (container) { |
| const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 20; |
|
|
| |
| if (isAtBottom) { |
| shouldFollowScrollRef.current = true; |
| } else if (!isFormInteractionRef.current && !isReceivingResponseRef.current) { |
| shouldFollowScrollRef.current = false; |
| } |
| } |
| }, 30); |
|
|
| |
| container.addEventListener('wheel', handleWheel as EventListener); |
| container.addEventListener('scroll', handleScroll as EventListener); |
|
|
| return () => { |
| container.removeEventListener('wheel', handleWheel as EventListener); |
| container.removeEventListener('scroll', handleScroll as EventListener); |
| }; |
| }, []); |
|
|
| |
| useEffect(() => { |
| const form = document.querySelector('form'); |
| if (!form) return; |
|
|
| const handleFormMouseDown = () => { |
| |
| isFormInteractionRef.current = true; |
|
|
| |
| setTimeout(() => { |
| isFormInteractionRef.current = false; |
| }, 500); |
| }; |
|
|
| form.addEventListener('mousedown', handleFormMouseDown); |
|
|
| return () => { |
| form.removeEventListener('mousedown', handleFormMouseDown); |
| }; |
| }, []); |
|
|
| |
| const debouncedMessages = useDebounce(messages, 150) |
| useEffect(() => { |
| |
| if (shouldFollowScrollRef.current) { |
| |
| scrollToBottom() |
| } |
| }, [debouncedMessages, scrollToBottom]) |
|
|
|
|
| const clearMessages = useCallback(() => { |
| setMessages([]) |
| useSettingsStore.getState().setRetrievalHistory([]) |
| }, [setMessages]) |
|
|
| |
| const handleCopyMessage = useCallback(async (message: MessageWithError) => { |
| const contentToCopy = message.role === 'user' |
| ? (message.content || '') |
| : (message.displayContent !== undefined ? message.displayContent : (message.content || '')); |
|
|
| if (!contentToCopy.trim()) { |
| toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy')); |
| return; |
| } |
|
|
| try { |
| const result = await copyToClipboard(contentToCopy); |
|
|
| if (result.success) { |
| |
| const methodMessages: Record<string, string> = { |
| 'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'), |
| 'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'), |
| 'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'), |
| 'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard') |
| }; |
|
|
| toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')); |
| } else { |
| |
| if (result.method === 'fallback') { |
| toast.error( |
| result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'), |
| { |
| description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually') |
| } |
| ); |
| } else { |
| toast.error( |
| t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'), |
| { |
| description: result.error |
| } |
| ); |
| } |
| } |
| } catch (err) { |
| console.error('Clipboard operation failed:', err); |
| toast.error( |
| t('retrievePanel.chatMessage.copyError', 'Copy operation failed'), |
| { |
| description: err instanceof Error ? err.message : 'Unknown error occurred' |
| } |
| ); |
| } |
| }, [t]) |
|
|
| return ( |
| <div className="flex size-full gap-2 px-2 pb-12 overflow-hidden"> |
| <div className="flex grow flex-col gap-4"> |
| <div className="relative grow"> |
| <div |
| ref={messagesContainerRef} |
| className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2" |
| onClick={() => { |
| if (shouldFollowScrollRef.current) { |
| shouldFollowScrollRef.current = false; |
| } |
| }} |
| > |
| <div className="flex min-h-0 flex-1 flex-col gap-2"> |
| {messages.length === 0 ? ( |
| <div className="text-muted-foreground flex h-full items-center justify-center text-lg"> |
| {t('retrievePanel.retrieval.startPrompt')} |
| </div> |
| ) : ( |
| messages.map((message) => { // Remove unused idx |
| // isComplete logic is now handled internally based on message.mermaidRendered |
| return ( |
| <div |
| key={message.id} // Use stable ID for key |
| className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`} |
| > |
| {message.role === 'user' && ( |
| <Button |
| onClick={() => handleCopyMessage(message)} |
| className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0" |
| tooltip={t('retrievePanel.chatMessage.copyTooltip')} |
| variant="ghost" |
| size="icon" |
| > |
| <CopyIcon className="size-4" /> |
| </Button> |
| )} |
| <ChatMessage message={message} isTabActive={isRetrievalTabActive} /> |
| {message.role === 'assistant' && ( |
| <Button |
| onClick={() => handleCopyMessage(message)} |
| className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0" |
| tooltip={t('retrievePanel.chatMessage.copyTooltip')} |
| variant="ghost" |
| size="icon" |
| > |
| <CopyIcon className="size-4" /> |
| </Button> |
| )} |
| </div> |
| ); |
| }) |
| )} |
| <div ref={messagesEndRef} className="pb-1" /> |
| </div> |
| </div> |
| </div> |
|
|
| <form |
| onSubmit={handleSubmit} |
| className="flex shrink-0 items-center gap-2" |
| autoComplete="on" |
| method="post" |
| action="#" |
| role="search" |
| > |
| {/* Hidden submit button to ensure form meets HTML standards */} |
| <input type="submit" style={{ display: 'none' }} tabIndex={-1} /> |
| <Button |
| type="button" |
| variant="outline" |
| onClick={clearMessages} |
| disabled={isLoading} |
| size="sm" |
| > |
| <EraserIcon /> |
| {t('retrievePanel.retrieval.clear')} |
| </Button> |
| <div className="flex-1 relative"> |
| <label htmlFor="query-input" className="sr-only"> |
| {t('retrievePanel.retrieval.placeholder')} |
| </label> |
| {hasMultipleLines ? ( |
| <Textarea |
| ref={inputRef as React.RefObject<HTMLTextAreaElement>} |
| id="query-input" |
| autoComplete="on" |
| className="w-full min-h-[40px] max-h-[120px] overflow-y-auto" |
| value={inputValue} |
| onChange={handleChange} |
| onKeyDown={handleKeyDown} |
| onPaste={handlePaste} |
| placeholder={t('retrievePanel.retrieval.placeholder')} |
| disabled={isLoading} |
| rows={1} |
| style={{ |
| resize: 'none', |
| height: 'auto', |
| minHeight: '40px', |
| maxHeight: '120px' |
| }} |
| onInput={(e: React.FormEvent<HTMLTextAreaElement>) => { |
| const target = e.target as HTMLTextAreaElement |
| requestAnimationFrame(() => { |
| target.style.height = 'auto' |
| target.style.height = Math.min(target.scrollHeight, 120) + 'px' |
| }) |
| }} |
| /> |
| ) : ( |
| <Input |
| ref={inputRef as React.RefObject<HTMLInputElement>} |
| id="query-input" |
| autoComplete="on" |
| className="w-full" |
| value={inputValue} |
| onChange={handleChange} |
| onKeyDown={handleKeyDown} |
| onPaste={handlePaste} |
| placeholder={t('retrievePanel.retrieval.placeholder')} |
| disabled={isLoading} |
| /> |
| )} |
| {/* Error message below input */} |
| {inputError && ( |
| <div className="absolute left-0 top-full mt-1 text-xs text-red-500">{inputError}</div> |
| )} |
| </div> |
| <Button type="submit" variant="default" disabled={isLoading} size="sm"> |
| <SendIcon /> |
| {t('retrievePanel.retrieval.send')} |
| </Button> |
| </form> |
| </div> |
| <QuerySettings /> |
| </div> |
| ) |
| } |
|
|