From f22f479b1f2424e0642f225c630dcc84a037047e Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 12 Dec 2025 15:13:44 -0300 Subject: [PATCH] use fuse.js instead of query to search for filters --- frontend/app/chat/_components/chat-input.tsx | 1069 +++++++++--------- 1 file changed, 539 insertions(+), 530 deletions(-) diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx index 29b081c5..cd343eda 100644 --- a/frontend/app/chat/_components/chat-input.tsx +++ b/frontend/app/chat/_components/chat-input.tsx @@ -1,11 +1,12 @@ +import Fuse from "fuse.js"; import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { - forwardRef, - useImperativeHandle, - useMemo, - useRef, - useState, + forwardRef, + useImperativeHandle, + useMemo, + useRef, + useState, } from "react"; import { useDropzone } from "react-dropzone"; import TextareaAutosize from "react-textarea-autosize"; @@ -13,585 +14,593 @@ import { toast } from "sonner"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverAnchor, - PopoverContent, + Popover, + PopoverAnchor, + PopoverContent, } from "@/components/ui/popover"; import { useFileDrag } from "@/hooks/use-file-drag"; import { cn } from "@/lib/utils"; -import { useGetFiltersSearchQuery } from "../../api/queries/useGetFiltersSearchQuery"; +import { useGetAllFiltersQuery } from "../../api/queries/useGetAllFiltersQuery"; import type { KnowledgeFilterData } from "../_types/types"; import { FilePreview } from "./file-preview"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; export interface ChatInputHandle { - focusInput: () => void; - clickFileInput: () => void; + focusInput: () => void; + clickFileInput: () => void; } interface ChatInputProps { - input: string; - loading: boolean; - isUploading: boolean; - selectedFilter: KnowledgeFilterData | null; - parsedFilterData: { color?: FilterColor } | null; - uploadedFile: File | null; - onSubmit: (e: React.FormEvent) => void; - onChange: (value: string) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onFilterSelect: (filter: KnowledgeFilterData | null) => void; - onFilePickerClick: () => void; - setSelectedFilter: (filter: KnowledgeFilterData | null) => void; - setIsFilterHighlighted: (highlighted: boolean) => void; - onFileSelected: (file: File | null) => void; + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (value: string) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( - ( - { - input, - loading, - isUploading, - selectedFilter, - parsedFilterData, - uploadedFile, - onSubmit, - onChange, - onKeyDown, - onFilterSelect, - onFilePickerClick, - setSelectedFilter, - setIsFilterHighlighted, - onFileSelected, - }, - ref, - ) => { - const inputRef = useRef(null); - const fileInputRef = useRef(null); - const [textareaHeight, setTextareaHeight] = useState(0); - const isDragging = useFileDrag(); + ( + { + input, + loading, + isUploading, + selectedFilter, + parsedFilterData, + uploadedFile, + onSubmit, + onChange, + onKeyDown, + onFilterSelect, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + onFileSelected, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); + const isDragging = useFileDrag(); - // Internal state for filter dropdown - const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); - const [filterSearchTerm, setFilterSearchTerm] = useState(""); - const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); - const [anchorPosition, setAnchorPosition] = useState<{ - x: number; - y: number; - } | null>(null); + // Internal state for filter dropdown + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); - // Fetch filters using the query hook - const { data: availableFilters = [] } = useGetFiltersSearchQuery( - filterSearchTerm, - 20, - { enabled: isFilterDropdownOpen }, - ); + // Fetch all filters once when dropdown opens + const { data: allFilters = [] } = useGetAllFiltersQuery({ + enabled: isFilterDropdownOpen, + }); - // Filter available filters based on search term - const filteredFilters = useMemo(() => { - return availableFilters.filter((filter) => - filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), - ); - }, [availableFilters, filterSearchTerm]); + // Use fuse.js for fuzzy search on client side + const filteredFilters = useMemo(() => { + if (!filterSearchTerm) { + return allFilters.slice(0, 20); // Return first 20 when no search term + } - const { getRootProps, getInputProps } = useDropzone({ - accept: { - "application/pdf": [".pdf"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - [".docx"], - "text/markdown": [".md"], - }, - maxFiles: 1, - disabled: !isDragging, - onDrop: (acceptedFiles, fileRejections) => { - if (fileRejections.length > 0) { - const message = fileRejections.at(0)?.errors.at(0)?.message; - toast.error(message || "Failed to upload file"); - return; - } - onFileSelected(acceptedFiles[0]); - }, - }); + const fuse = new Fuse(allFilters, { + keys: ["name", "description"], + threshold: 0.3, // 0.0 = perfect match, 1.0 = match anything + includeScore: true, + minMatchCharLength: 1, + }); - useImperativeHandle(ref, () => ({ - focusInput: () => { - inputRef.current?.focus(); - }, - clickFileInput: () => { - fileInputRef.current?.click(); - }, - })); + const results = fuse.search(filterSearchTerm); + return results.map((result) => result.item).slice(0, 20); + }, [allFilters, filterSearchTerm]); - const handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - onFileSelected(files[0]); - } else { - onFileSelected(null); - } - }; + const { getRootProps, getInputProps } = useDropzone({ + accept: { + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/markdown": [".md"], + }, + maxFiles: 1, + disabled: !isDragging, + onDrop: (acceptedFiles, fileRejections) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message; + toast.error(message || "Failed to upload file"); + return; + } + onFileSelected(acceptedFiles[0]); + }, + }); - const onAtClick = () => { - if (!isFilterDropdownOpen) { - setIsFilterDropdownOpen(true); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); - // Get button position for popover anchoring - const button = document.querySelector( - "[data-filter-button]", - ) as HTMLElement; - if (button) { - const rect = button.getBoundingClientRect(); - setAnchorPosition({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - 12, - }); - } - } else { - setIsFilterDropdownOpen(false); - setAnchorPosition(null); - } - }; + const handleFilePickerChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; - const handleFilterSelect = (filter: KnowledgeFilterData | null) => { - onFilterSelect(filter); + const onAtClick = () => { + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen(true); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); - // Remove the @searchTerm from the input - const words = input.split(" "); - const lastWord = words[words.length - 1]; + // Get button position for popover anchoring + const button = document.querySelector( + "[data-filter-button]", + ) as HTMLElement; + if (button) { + const rect = button.getBoundingClientRect(); + setAnchorPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 - 12, + }); + } + } else { + setIsFilterDropdownOpen(false); + setAnchorPosition(null); + } + }; - if (lastWord.startsWith("@")) { - // Remove the @search term - words.pop(); - onChange(words.join(" ") + (words.length > 0 ? " " : "")); - } + const handleFilterSelect = (filter: KnowledgeFilterData | null) => { + onFilterSelect(filter); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - }; + // Remove the @searchTerm from the input + const words = input.split(" "); + const lastWord = words[words.length - 1]; - const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - onChange(newValue); // Call parent's onChange with the string value + if (lastWord.startsWith("@")) { + // Remove the @search term + words.pop(); + onChange(words.join(" ") + (words.length > 0 ? " " : "")); + } - // Find if there's an @ at the start of the last word - const words = newValue.split(" "); - const lastWord = words[words.length - 1]; + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + }; - if (lastWord.startsWith("@")) { - const searchTerm = lastWord.slice(1); // Remove the @ - setFilterSearchTerm(searchTerm); - setSelectedFilterIndex(0); + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); // Call parent's onChange with the string value - // Only set anchor position when @ is first detected (search term is empty) - if (searchTerm === "") { - const getCursorPosition = (textarea: HTMLTextAreaElement) => { - // Create a hidden div with the same styles as the textarea - const div = document.createElement("div"); - const computedStyle = getComputedStyle(textarea); + // Find if there's an @ at the start of the last word + const words = newValue.split(" "); + const lastWord = words[words.length - 1]; - // Copy all computed styles to the hidden div - for (const style of computedStyle) { - (div.style as unknown as Record)[style] = - computedStyle.getPropertyValue(style); - } + if (lastWord.startsWith("@")) { + const searchTerm = lastWord.slice(1); // Remove the @ + setFilterSearchTerm(searchTerm); + setSelectedFilterIndex(0); - // Set the div to be hidden but not un-rendered - div.style.position = "absolute"; - div.style.visibility = "hidden"; - div.style.whiteSpace = "pre-wrap"; - div.style.wordWrap = "break-word"; - div.style.overflow = "hidden"; - div.style.height = "auto"; - div.style.width = `${textarea.getBoundingClientRect().width}px`; + // Only set anchor position when @ is first detected (search term is empty) + if (searchTerm === "") { + const getCursorPosition = (textarea: HTMLTextAreaElement) => { + // Create a hidden div with the same styles as the textarea + const div = document.createElement("div"); + const computedStyle = getComputedStyle(textarea); - // Get the text up to the cursor position - const cursorPos = textarea.selectionStart || 0; - const textBeforeCursor = textarea.value.substring(0, cursorPos); + // Copy all computed styles to the hidden div + for (const style of computedStyle) { + (div.style as unknown as Record)[style] = + computedStyle.getPropertyValue(style); + } - // Add the text before cursor - div.textContent = textBeforeCursor; + // Set the div to be hidden but not un-rendered + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + div.style.wordWrap = "break-word"; + div.style.overflow = "hidden"; + div.style.height = "auto"; + div.style.width = `${textarea.getBoundingClientRect().width}px`; - // Create a span to mark the end position - const span = document.createElement("span"); - span.textContent = "|"; // Cursor marker - div.appendChild(span); + // Get the text up to the cursor position + const cursorPos = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, cursorPos); - // Add the text after cursor to handle word wrapping - const textAfterCursor = textarea.value.substring(cursorPos); - div.appendChild(document.createTextNode(textAfterCursor)); + // Add the text before cursor + div.textContent = textBeforeCursor; - // Add the div to the document temporarily - document.body.appendChild(div); + // Create a span to mark the end position + const span = document.createElement("span"); + span.textContent = "|"; // Cursor marker + div.appendChild(span); - // Get positions - const inputRect = textarea.getBoundingClientRect(); - const divRect = div.getBoundingClientRect(); - const spanRect = span.getBoundingClientRect(); + // Add the text after cursor to handle word wrapping + const textAfterCursor = textarea.value.substring(cursorPos); + div.appendChild(document.createTextNode(textAfterCursor)); - // Calculate the cursor position relative to the input - const x = inputRect.left + (spanRect.left - divRect.left); - const y = inputRect.top + (spanRect.top - divRect.top); + // Add the div to the document temporarily + document.body.appendChild(div); - // Clean up - document.body.removeChild(div); + // Get positions + const inputRect = textarea.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); - return { x, y }; - }; + // Calculate the cursor position relative to the input + const x = inputRect.left + (spanRect.left - divRect.left); + const y = inputRect.top + (spanRect.top - divRect.top); - const pos = getCursorPosition(e.target); - setAnchorPosition(pos); - } + // Clean up + document.body.removeChild(div); - if (!isFilterDropdownOpen) { - setIsFilterDropdownOpen(true); - } - } else if (isFilterDropdownOpen) { - // Close dropdown if @ is no longer present - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - } - }; + return { x, y }; + }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (isFilterDropdownOpen) { - if (e.key === "Escape") { - e.preventDefault(); - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - inputRef.current?.focus(); - return; - } + const pos = getCursorPosition(e.target); + setAnchorPosition(pos); + } - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev < filteredFilters.length - 1 ? prev + 1 : 0, - ); - return; - } + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen(true); + } + } else if (isFilterDropdownOpen) { + // Close dropdown if @ is no longer present + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + } + }; - if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedFilterIndex((prev) => - prev > 0 ? prev - 1 : filteredFilters.length - 1, - ); - return; - } + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isFilterDropdownOpen) { + if (e.key === "Escape") { + e.preventDefault(); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + inputRef.current?.focus(); + return; + } - if (e.key === "Enter") { - // Check if we're at the end of an @ mention - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev < filteredFilters.length - 1 ? prev + 1 : 0, + ); + return; + } - if ( - lastWord.startsWith("@") && - filteredFilters[selectedFilterIndex] - ) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev > 0 ? prev - 1 : filteredFilters.length - 1, + ); + return; + } - if (e.key === " ") { - // Select filter on space if we're typing an @ mention - const cursorPos = e.currentTarget.selectionStart || 0; - const textBeforeCursor = input.slice(0, cursorPos); - const words = textBeforeCursor.split(" "); - const lastWord = words[words.length - 1]; + if (e.key === "Enter") { + // Check if we're at the end of an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; - if ( - lastWord.startsWith("@") && - filteredFilters[selectedFilterIndex] - ) { - e.preventDefault(); - handleFilterSelect(filteredFilters[selectedFilterIndex]); - return; - } - } - } + if ( + lastWord.startsWith("@") && + filteredFilters[selectedFilterIndex] + ) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } - // Pass through to parent onKeyDown for other key handling - onKeyDown(e); - }; + if (e.key === " ") { + // Select filter on space if we're typing an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; - return ( -
-
- {/* Outer container - flex-col to stack file preview above input */} -
- - {/* File Preview Section - Always above */} - - {uploadedFile && ( - - { - onFileSelected(null); - }} - isUploading={isUploading} - /> - - )} - - - {isDragging && ( - -

- Add files to conversation -

-

- Text formats and image files.{" "} - 10 files per chat,{" "} - 150 MB each. -

-
- )} -
- {/* Main Input Container - flex-row or flex-col based on textarea height */} -
40 ? "flex-col" : "flex-row items-center" - }`} - > - {/* Filter + Textarea Section */} -
40 ? "w-full" : "flex-1"}`} - > - {textareaHeight <= 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- setTextareaHeight(height)} - maxRows={7} - autoComplete="off" - minRows={1} - placeholder="Ask a question..." - disabled={loading} - className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} - rows={1} - /> -
-
+ if ( + lastWord.startsWith("@") && + filteredFilters[selectedFilterIndex] + ) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + } - {/* Action Buttons Section */} -
40 ? "justify-between w-full" : ""}`} - > - {textareaHeight > 40 && - (selectedFilter ? ( - { - setSelectedFilter(null); - setIsFilterHighlighted(false); - }} - /> - ) : ( - - ))} -
- - -
-
-
-
- + // Pass through to parent onKeyDown for other key handling + onKeyDown(e); + }; - { - setIsFilterDropdownOpen(open); - }} - > - {anchorPosition && ( - -
- - )} - { - // Prevent auto focus on the popover content - e.preventDefault(); - // Keep focus on the input - }} - > -
- {filterSearchTerm && ( -
- Searching: @{filterSearchTerm} -
- )} - {availableFilters.length === 0 ? ( -
- No knowledge filters available -
- ) : ( - <> - {!filterSearchTerm && ( - - )} - {filteredFilters.map((filter, index) => ( - - ))} - {filteredFilters.length === 0 && filterSearchTerm && ( -
- No filters match "{filterSearchTerm}" -
- )} - - )} -
-
- - -
- ); - }, + return ( +
+
+ {/* Outer container - flex-col to stack file preview above input */} +
+ + {/* File Preview Section - Always above */} + + {uploadedFile && ( + + { + onFileSelected(null); + }} + isUploading={isUploading} + /> + + )} + + + {isDragging && ( + +

+ Add files to conversation +

+

+ Text formats and image files.{" "} + 10 files per chat,{" "} + 150 MB each. +

+
+ )} +
+ {/* Main Input Container - flex-row or flex-col based on textarea height */} +
40 ? "flex-col" : "flex-row items-center" + }`} + > + {/* Filter + Textarea Section */} +
40 ? "w-full" : "flex-1"}`} + > + {textareaHeight <= 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ setTextareaHeight(height)} + maxRows={7} + autoComplete="off" + minRows={1} + placeholder="Ask a question..." + disabled={loading} + className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} + rows={1} + /> +
+
+ + {/* Action Buttons Section */} +
40 ? "justify-between w-full" : ""}`} + > + {textareaHeight > 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ + +
+
+
+
+ + + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {allFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {filteredFilters.map((filter, index) => ( + + ))} + {filteredFilters.length === 0 && filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, ); ChatInput.displayName = "ChatInput";