move filter logic to chat-input and use query instead of manual fetch
This commit is contained in:
parent
0e60b7b6c9
commit
f7007b625d
2 changed files with 1403 additions and 1449 deletions
|
|
@ -1,6 +1,12 @@
|
|||
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -13,6 +19,7 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { useFileDrag } from "@/hooks/use-file-drag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGetFiltersSearchQuery } from "../../api/queries/useGetFiltersSearchQuery";
|
||||
import type { KnowledgeFilterData } from "../_types/types";
|
||||
import { FilePreview } from "./file-preview";
|
||||
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
||||
|
|
@ -27,22 +34,15 @@ interface ChatInputProps {
|
|||
loading: boolean;
|
||||
isUploading: boolean;
|
||||
selectedFilter: KnowledgeFilterData | null;
|
||||
isFilterDropdownOpen: boolean;
|
||||
availableFilters: KnowledgeFilterData[];
|
||||
filterSearchTerm: string;
|
||||
selectedFilterIndex: number;
|
||||
anchorPosition: { x: number; y: number } | null;
|
||||
parsedFilterData: { color?: FilterColor } | null;
|
||||
uploadedFile: File | null;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onChange: (value: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||
onAtClick: () => void;
|
||||
onFilePickerClick: () => void;
|
||||
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||
onFileSelected: (file: File | null) => void;
|
||||
}
|
||||
|
||||
|
|
@ -53,22 +53,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
loading,
|
||||
isUploading,
|
||||
selectedFilter,
|
||||
isFilterDropdownOpen,
|
||||
availableFilters,
|
||||
filterSearchTerm,
|
||||
selectedFilterIndex,
|
||||
anchorPosition,
|
||||
parsedFilterData,
|
||||
uploadedFile,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onFilterSelect,
|
||||
onAtClick,
|
||||
onFilePickerClick,
|
||||
setSelectedFilter,
|
||||
setIsFilterHighlighted,
|
||||
setIsFilterDropdownOpen,
|
||||
onFileSelected,
|
||||
},
|
||||
ref,
|
||||
|
|
@ -78,6 +71,29 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
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);
|
||||
|
||||
// Fetch filters using the query hook
|
||||
const { data: availableFilters = [] } = useGetFiltersSearchQuery(
|
||||
filterSearchTerm,
|
||||
20,
|
||||
{ enabled: isFilterDropdownOpen },
|
||||
);
|
||||
|
||||
// Filter available filters based on search term
|
||||
const filteredFilters = useMemo(() => {
|
||||
return availableFilters.filter((filter) =>
|
||||
filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()),
|
||||
);
|
||||
}, [availableFilters, filterSearchTerm]);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
|
|
@ -116,6 +132,196 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
}
|
||||
};
|
||||
|
||||
const onAtClick = () => {
|
||||
if (!isFilterDropdownOpen) {
|
||||
setIsFilterDropdownOpen(true);
|
||||
setFilterSearchTerm("");
|
||||
setSelectedFilterIndex(0);
|
||||
|
||||
// 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 handleFilterSelect = (filter: KnowledgeFilterData | null) => {
|
||||
onFilterSelect(filter);
|
||||
|
||||
// Remove the @searchTerm from the input
|
||||
const words = input.split(" ");
|
||||
const lastWord = words[words.length - 1];
|
||||
|
||||
if (lastWord.startsWith("@")) {
|
||||
// Remove the @search term
|
||||
words.pop();
|
||||
onChange(words.join(" ") + (words.length > 0 ? " " : ""));
|
||||
}
|
||||
|
||||
setIsFilterDropdownOpen(false);
|
||||
setFilterSearchTerm("");
|
||||
setSelectedFilterIndex(0);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue); // Call parent's onChange with the string value
|
||||
|
||||
// Find if there's an @ at the start of the last word
|
||||
const words = newValue.split(" ");
|
||||
const lastWord = words[words.length - 1];
|
||||
|
||||
if (lastWord.startsWith("@")) {
|
||||
const searchTerm = lastWord.slice(1); // Remove the @
|
||||
setFilterSearchTerm(searchTerm);
|
||||
setSelectedFilterIndex(0);
|
||||
|
||||
// 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);
|
||||
|
||||
// Copy all computed styles to the hidden div
|
||||
for (const style of computedStyle) {
|
||||
(div.style as unknown as Record<string, string>)[style] =
|
||||
computedStyle.getPropertyValue(style);
|
||||
}
|
||||
|
||||
// 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`;
|
||||
|
||||
// Get the text up to the cursor position
|
||||
const cursorPos = textarea.selectionStart || 0;
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
||||
|
||||
// Add the text before cursor
|
||||
div.textContent = textBeforeCursor;
|
||||
|
||||
// Create a span to mark the end position
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "|"; // Cursor marker
|
||||
div.appendChild(span);
|
||||
|
||||
// Add the text after cursor to handle word wrapping
|
||||
const textAfterCursor = textarea.value.substring(cursorPos);
|
||||
div.appendChild(document.createTextNode(textAfterCursor));
|
||||
|
||||
// Add the div to the document temporarily
|
||||
document.body.appendChild(div);
|
||||
|
||||
// Get positions
|
||||
const inputRect = textarea.getBoundingClientRect();
|
||||
const divRect = div.getBoundingClientRect();
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
|
||||
// Calculate the cursor position relative to the input
|
||||
const x = inputRect.left + (spanRect.left - divRect.left);
|
||||
const y = inputRect.top + (spanRect.top - divRect.top);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(div);
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const pos = getCursorPosition(e.target);
|
||||
setAnchorPosition(pos);
|
||||
}
|
||||
|
||||
if (!isFilterDropdownOpen) {
|
||||
setIsFilterDropdownOpen(true);
|
||||
}
|
||||
} else if (isFilterDropdownOpen) {
|
||||
// Close dropdown if @ is no longer present
|
||||
setIsFilterDropdownOpen(false);
|
||||
setFilterSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isFilterDropdownOpen) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setIsFilterDropdownOpen(false);
|
||||
setFilterSearchTerm("");
|
||||
setSelectedFilterIndex(0);
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedFilterIndex((prev) =>
|
||||
prev < filteredFilters.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedFilterIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredFilters.length - 1,
|
||||
);
|
||||
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 (
|
||||
lastWord.startsWith("@") &&
|
||||
filteredFilters[selectedFilterIndex]
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleFilterSelect(filteredFilters[selectedFilterIndex]);
|
||||
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 (
|
||||
lastWord.startsWith("@") &&
|
||||
filteredFilters[selectedFilterIndex]
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleFilterSelect(filteredFilters[selectedFilterIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass through to parent onKeyDown for other key handling
|
||||
onKeyDown(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={onSubmit} className="relative">
|
||||
|
|
@ -209,8 +415,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onHeightChange={(height) => setTextareaHeight(height)}
|
||||
maxRows={7}
|
||||
autoComplete="off"
|
||||
|
|
@ -336,7 +542,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
{!filterSearchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterSelect(null)}
|
||||
onClick={() => handleFilterSelect(null)}
|
||||
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
||||
selectedFilterIndex === -1 ? "bg-muted/50" : ""
|
||||
}`}
|
||||
|
|
@ -347,46 +553,35 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
{availableFilters
|
||||
.filter((filter) =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase()),
|
||||
)
|
||||
.map((filter, index) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
onClick={() => onFilterSelect(filter)}
|
||||
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
||||
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium truncate">
|
||||
{filter.name}
|
||||
</div>
|
||||
{filter.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{filter.description}
|
||||
</div>
|
||||
)}
|
||||
{filteredFilters.map((filter, index) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
onClick={() => handleFilterSelect(filter)}
|
||||
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
||||
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium truncate">
|
||||
{filter.name}
|
||||
</div>
|
||||
{selectedFilter?.id === filter.id && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
{filter.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{filter.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{availableFilters.filter((filter) =>
|
||||
filter.name
|
||||
.toLowerCase()
|
||||
.includes(filterSearchTerm.toLowerCase()),
|
||||
).length === 0 &&
|
||||
filterSearchTerm && (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No filters match "{filterSearchTerm}"
|
||||
</div>
|
||||
)}
|
||||
{selectedFilter?.id === filter.id && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredFilters.length === 0 && filterSearchTerm && (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||
No filters match "{filterSearchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue