From 425d5a61d0d1ab13f0e38efa641eba38459e065a Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 12 Dec 2025 15:13:19 -0300 Subject: [PATCH 1/4] Added fuse.js --- frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 42883ac9..b0422ed0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dotenv": "^17.2.3", + "fuse.js": "^7.1.0", "lucide-react": "^0.525.0", "motion": "^12.23.12", "next": "15.5.7", @@ -3429,6 +3430,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index eaf9379c..c87132f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dotenv": "^17.2.3", + "fuse.js": "^7.1.0", "lucide-react": "^0.525.0", "motion": "^12.23.12", "next": "15.5.7", From 8ca0f84cd8b25a9e6b9dc6496c42698afd5a069e Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 12 Dec 2025 15:13:27 -0300 Subject: [PATCH 2/4] created useGetAllFiltersQuery --- .../app/api/queries/useGetAllFiltersQuery.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 frontend/app/api/queries/useGetAllFiltersQuery.ts diff --git a/frontend/app/api/queries/useGetAllFiltersQuery.ts b/frontend/app/api/queries/useGetAllFiltersQuery.ts new file mode 100644 index 00000000..5264d981 --- /dev/null +++ b/frontend/app/api/queries/useGetAllFiltersQuery.ts @@ -0,0 +1,36 @@ +import { + type UseQueryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { KnowledgeFilter } from "./useGetFiltersSearchQuery"; + +export const useGetAllFiltersQuery = ( + options?: Omit, "queryKey" | "queryFn">, +) => { + const queryClient = useQueryClient(); + + async function getAllFilters(): Promise { + const response = await fetch("/api/knowledge-filter/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "", limit: 1000 }), // Fetch all filters + }); + + const json = await response.json(); + if (!response.ok || !json.success) { + // ensure we always return a KnowledgeFilter[] to satisfy the return type + return []; + } + return (json.filters || []) as KnowledgeFilter[]; + } + + return useQuery( + { + queryKey: ["knowledge-filters", "all"], + queryFn: getAllFilters, + ...options, + }, + queryClient, + ); +}; From f22f479b1f2424e0642f225c630dcc84a037047e Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 12 Dec 2025 15:13:44 -0300 Subject: [PATCH 3/4] 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"; From fb554445ce2ee1cfd8f62aea4dcd07cdcb371f8f Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Fri, 12 Dec 2025 15:13:56 -0300 Subject: [PATCH 4/4] use new hook to get all knowledges --- frontend/components/knowledge-filter-list.tsx | 277 +++++++++--------- 1 file changed, 135 insertions(+), 142 deletions(-) diff --git a/frontend/components/knowledge-filter-list.tsx b/frontend/components/knowledge-filter-list.tsx index 9e249f4c..d72ecfd7 100644 --- a/frontend/components/knowledge-filter-list.tsx +++ b/frontend/components/knowledge-filter-list.tsx @@ -1,165 +1,158 @@ "use client"; import { Plus } from "lucide-react"; -import { useState } from "react"; -import { - type KnowledgeFilter, - useGetFiltersSearchQuery, -} from "@/app/api/queries/useGetFiltersSearchQuery"; -import { cn } from "@/lib/utils"; +import { useGetAllFiltersQuery } from "@/app/api/queries/useGetAllFiltersQuery"; +import type { KnowledgeFilter } from "@/app/api/queries/useGetFiltersSearchQuery"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { cn } from "@/lib/utils"; import { - type FilterColor, - type IconKey, - iconKeyToComponent, + type FilterColor, + type IconKey, + iconKeyToComponent, } from "./filter-icon-popover"; import { filterAccentClasses } from "./knowledge-filter-panel"; interface ParsedQueryData { - query: string; - filters: { - data_sources: string[]; - document_types: string[]; - owners: string[]; - }; - limit: number; - scoreThreshold: number; - color: FilterColor; - icon: IconKey; + query: string; + filters: { + data_sources: string[]; + document_types: string[]; + owners: string[]; + }; + limit: number; + scoreThreshold: number; + color: FilterColor; + icon: IconKey; } interface KnowledgeFilterListProps { - selectedFilter: KnowledgeFilter | null; - onFilterSelect: (filter: KnowledgeFilter | null) => void; + selectedFilter: KnowledgeFilter | null; + onFilterSelect: (filter: KnowledgeFilter | null) => void; } export function KnowledgeFilterList({ - selectedFilter, - onFilterSelect, + selectedFilter, + onFilterSelect, }: KnowledgeFilterListProps) { - const [searchQuery] = useState(""); - const { startCreateMode } = useKnowledgeFilter(); + const { startCreateMode } = useKnowledgeFilter(); - const { data, isFetching: loading } = useGetFiltersSearchQuery( - searchQuery, - 20, - ); + const { data, isFetching: loading } = useGetAllFiltersQuery(); - const filters = data || []; + const filters = data || []; - const handleFilterSelect = (filter: KnowledgeFilter) => { - if (filter.id === selectedFilter?.id) { - onFilterSelect(null); - return; - } - onFilterSelect(filter); - }; + const handleFilterSelect = (filter: KnowledgeFilter) => { + if (filter.id === selectedFilter?.id) { + onFilterSelect(null); + return; + } + onFilterSelect(filter); + }; - const handleCreateNew = () => { - startCreateMode(); - }; + const handleCreateNew = () => { + startCreateMode(); + }; - const parseQueryData = (queryData: string): ParsedQueryData => { - return JSON.parse(queryData) as ParsedQueryData; - }; + const parseQueryData = (queryData: string): ParsedQueryData => { + return JSON.parse(queryData) as ParsedQueryData; + }; - return ( -
-
-
-
-

- Knowledge Filters -

- -
-
- {loading ? ( -
- Loading... -
- ) : filters.length === 0 ? ( -
- {searchQuery ? "No filters found" : "No saved filters"} -
- ) : ( - filters.map((filter) => ( -
handleFilterSelect(filter)} - className={cn( - "flex items-center gap-3 px-3 py-2 w-full rounded-lg hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors", - selectedFilter?.id === filter.id && - "active bg-accent text-accent-foreground", - )} - > -
-
- {(() => { - const parsed = parseQueryData( - filter.query_data, - ) as ParsedQueryData; - const Icon = iconKeyToComponent(parsed.icon); - return ( -
- {Icon && } -
- ); - })()} -
- {filter.name} -
-
- {filter.description && ( -
- {filter.description} -
- )} -
-
- {new Date(filter.created_at).toLocaleDateString( - undefined, - { - month: "short", - day: "numeric", - year: "numeric", - }, - )} -
- - {(() => { - const dataSources = parseQueryData(filter.query_data) - .filters.data_sources; - if (dataSources[0] === "*") return "All sources"; - const count = dataSources.length; - return `${count} ${ - count === 1 ? "source" : "sources" - }`; - })()} - -
-
-
- )) - )} -
-
- {/* Create flow moved to panel create mode */} -
-
- ); + return ( +
+
+
+
+

+ Knowledge Filters +

+ +
+
+ {loading ? ( +
+ Loading... +
+ ) : filters.length === 0 ? ( +
+ No saved filters +
+ ) : ( + filters.map((filter) => ( +
handleFilterSelect(filter)} + className={cn( + "flex items-center gap-3 px-3 py-2 w-full rounded-lg hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors", + selectedFilter?.id === filter.id && + "active bg-accent text-accent-foreground", + )} + > +
+
+ {(() => { + const parsed = parseQueryData( + filter.query_data, + ) as ParsedQueryData; + const Icon = iconKeyToComponent(parsed.icon); + return ( +
+ {Icon && } +
+ ); + })()} +
+ {filter.name} +
+
+ {filter.description && ( +
+ {filter.description} +
+ )} +
+
+ {new Date(filter.created_at).toLocaleDateString( + undefined, + { + month: "short", + day: "numeric", + year: "numeric", + }, + )} +
+ + {(() => { + const dataSources = parseQueryData(filter.query_data) + .filters.data_sources; + if (dataSources[0] === "*") return "All sources"; + const count = dataSources.length; + return `${count} ${ + count === 1 ? "source" : "sources" + }`; + })()} + +
+
+
+ )) + )} +
+
+ {/* Create flow moved to panel create mode */} +
+
+ ); }