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 { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/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 { useDropzone } from "react-dropzone";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -13,6 +19,7 @@ import {
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { useFileDrag } from "@/hooks/use-file-drag";
|
import { useFileDrag } from "@/hooks/use-file-drag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useGetFiltersSearchQuery } from "../../api/queries/useGetFiltersSearchQuery";
|
||||||
import type { KnowledgeFilterData } from "../_types/types";
|
import type { KnowledgeFilterData } from "../_types/types";
|
||||||
import { FilePreview } from "./file-preview";
|
import { FilePreview } from "./file-preview";
|
||||||
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
||||||
|
|
@ -27,22 +34,15 @@ interface ChatInputProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
selectedFilter: KnowledgeFilterData | null;
|
selectedFilter: KnowledgeFilterData | null;
|
||||||
isFilterDropdownOpen: boolean;
|
|
||||||
availableFilters: KnowledgeFilterData[];
|
|
||||||
filterSearchTerm: string;
|
|
||||||
selectedFilterIndex: number;
|
|
||||||
anchorPosition: { x: number; y: number } | null;
|
|
||||||
parsedFilterData: { color?: FilterColor } | null;
|
parsedFilterData: { color?: FilterColor } | null;
|
||||||
uploadedFile: File | null;
|
uploadedFile: File | null;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
onChange: (value: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||||
onAtClick: () => void;
|
|
||||||
onFilePickerClick: () => void;
|
onFilePickerClick: () => void;
|
||||||
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||||
setIsFilterHighlighted: (highlighted: boolean) => void;
|
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
|
||||||
onFileSelected: (file: File | null) => void;
|
onFileSelected: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,22 +53,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
loading,
|
loading,
|
||||||
isUploading,
|
isUploading,
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
isFilterDropdownOpen,
|
|
||||||
availableFilters,
|
|
||||||
filterSearchTerm,
|
|
||||||
selectedFilterIndex,
|
|
||||||
anchorPosition,
|
|
||||||
parsedFilterData,
|
parsedFilterData,
|
||||||
uploadedFile,
|
uploadedFile,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onFilterSelect,
|
onFilterSelect,
|
||||||
onAtClick,
|
|
||||||
onFilePickerClick,
|
onFilePickerClick,
|
||||||
setSelectedFilter,
|
setSelectedFilter,
|
||||||
setIsFilterHighlighted,
|
setIsFilterHighlighted,
|
||||||
setIsFilterDropdownOpen,
|
|
||||||
onFileSelected,
|
onFileSelected,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
|
|
@ -78,6 +71,29 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
const [textareaHeight, setTextareaHeight] = useState(0);
|
const [textareaHeight, setTextareaHeight] = useState(0);
|
||||||
const isDragging = useFileDrag();
|
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({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
"application/pdf": [".pdf"],
|
"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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<form onSubmit={onSubmit} className="relative">
|
<form onSubmit={onSubmit} className="relative">
|
||||||
|
|
@ -209,8 +415,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onChange}
|
onChange={handleChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onHeightChange={(height) => setTextareaHeight(height)}
|
onHeightChange={(height) => setTextareaHeight(height)}
|
||||||
maxRows={7}
|
maxRows={7}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|
@ -336,7 +542,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
{!filterSearchTerm && (
|
{!filterSearchTerm && (
|
||||||
<button
|
<button
|
||||||
type="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 ${
|
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" : ""
|
selectedFilterIndex === -1 ? "bg-muted/50" : ""
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -347,17 +553,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{availableFilters
|
{filteredFilters.map((filter, index) => (
|
||||||
.filter((filter) =>
|
|
||||||
filter.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(filterSearchTerm.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((filter, index) => (
|
|
||||||
<button
|
<button
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterSelect(filter)}
|
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 ${
|
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" : ""
|
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -377,12 +577,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{availableFilters.filter((filter) =>
|
{filteredFilters.length === 0 && filterSearchTerm && (
|
||||||
filter.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(filterSearchTerm.toLowerCase()),
|
|
||||||
).length === 0 &&
|
|
||||||
filterSearchTerm && (
|
|
||||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||||
No filters match "{filterSearchTerm}"
|
No filters match "{filterSearchTerm}"
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -57,20 +57,9 @@ function ChatPage() {
|
||||||
>(new Set());
|
>(new Set());
|
||||||
// previousResponseIds now comes from useChat context
|
// previousResponseIds now comes from useChat context
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
|
|
||||||
const [availableFilters, setAvailableFilters] = useState<
|
|
||||||
KnowledgeFilterData[]
|
|
||||||
>([]);
|
|
||||||
const [filterSearchTerm, setFilterSearchTerm] = useState("");
|
|
||||||
const [selectedFilterIndex, setSelectedFilterIndex] = useState(0);
|
|
||||||
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false);
|
const [isFilterHighlighted, setIsFilterHighlighted] = useState(false);
|
||||||
const [dropdownDismissed, setDropdownDismissed] = useState(false);
|
|
||||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
const [isForkingInProgress, setIsForkingInProgress] = useState(false);
|
const [isForkingInProgress, setIsForkingInProgress] = useState(false);
|
||||||
const [anchorPosition, setAnchorPosition] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
const [waitingTooLong, setWaitingTooLong] = useState(false);
|
const [waitingTooLong, setWaitingTooLong] = useState(false);
|
||||||
|
|
||||||
|
|
@ -154,60 +143,6 @@ function ChatPage() {
|
||||||
};
|
};
|
||||||
}, [isStreamLoading, streamingMessage]);
|
}, [isStreamLoading, streamingMessage]);
|
||||||
|
|
||||||
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 handleEndpointChange = (newEndpoint: EndpointType) => {
|
const handleEndpointChange = (newEndpoint: EndpointType) => {
|
||||||
setEndpoint(newEndpoint);
|
setEndpoint(newEndpoint);
|
||||||
// Clear the conversation when switching endpoints to avoid response ID conflicts
|
// Clear the conversation when switching endpoints to avoid response ID conflicts
|
||||||
|
|
@ -329,55 +264,12 @@ function ChatPage() {
|
||||||
chatInputRef.current?.clickFileInput();
|
chatInputRef.current?.clickFileInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAvailableFilters = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/knowledge-filter/search", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: "",
|
|
||||||
limit: 20,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
setAvailableFilters(result.filters);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to load knowledge filters:", result.error);
|
|
||||||
setAvailableFilters([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load knowledge filters:", error);
|
|
||||||
setAvailableFilters([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterSelect = (filter: KnowledgeFilterData | null) => {
|
const handleFilterSelect = (filter: KnowledgeFilterData | null) => {
|
||||||
// Update conversation-specific filter
|
// Update conversation-specific filter
|
||||||
setConversationFilter(filter);
|
setConversationFilter(filter);
|
||||||
setIsFilterDropdownOpen(false);
|
|
||||||
setFilterSearchTerm("");
|
|
||||||
setIsFilterHighlighted(false);
|
setIsFilterHighlighted(false);
|
||||||
|
|
||||||
// Remove the @searchTerm from the input and replace with filter pill
|
|
||||||
const words = input.split(" ");
|
|
||||||
const lastWord = words[words.length - 1];
|
|
||||||
|
|
||||||
if (lastWord.startsWith("@")) {
|
|
||||||
// Remove the @search term
|
|
||||||
words.pop();
|
|
||||||
setInput(words.join(" ") + (words.length > 0 ? " " : ""));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset selected index when search term changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedFilterIndex(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-focus the input on component mount
|
// Auto-focus the input on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatInputRef.current?.focusInput();
|
chatInputRef.current?.focusInput();
|
||||||
|
|
@ -1047,162 +939,6 @@ function ChatPage() {
|
||||||
handleSendMessage(suggestion);
|
handleSendMessage(suggestion);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
// Handle backspace for filter clearing
|
|
||||||
if (e.key === "Backspace" && selectedFilter && input.trim() === "") {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (isFilterHighlighted) {
|
|
||||||
// Second backspace - remove the filter
|
|
||||||
setConversationFilter(null);
|
|
||||||
setIsFilterHighlighted(false);
|
|
||||||
} else {
|
|
||||||
// First backspace - highlight the filter
|
|
||||||
setIsFilterHighlighted(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFilterDropdownOpen) {
|
|
||||||
const filteredFilters = availableFilters.filter((filter) =>
|
|
||||||
filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsFilterDropdownOpen(false);
|
|
||||||
setFilterSearchTerm("");
|
|
||||||
setSelectedFilterIndex(0);
|
|
||||||
setDropdownDismissed(true);
|
|
||||||
|
|
||||||
// Keep focus on the textarea so user can continue typing normally
|
|
||||||
chatInputRef.current?.focusInput();
|
|
||||||
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 (space before cursor or end of input)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (input.trim() && !loading) {
|
|
||||||
// Trigger form submission by finding the form and calling submit
|
|
||||||
const form = e.currentTarget.closest("form");
|
|
||||||
if (form) {
|
|
||||||
form.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
setInput(newValue);
|
|
||||||
|
|
||||||
// Clear filter highlight when user starts typing
|
|
||||||
if (isFilterHighlighted) {
|
|
||||||
setIsFilterHighlighted(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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("@") && !dropdownDismissed) {
|
|
||||||
const searchTerm = lastWord.slice(1); // Remove the @
|
|
||||||
console.log("Setting search term:", searchTerm);
|
|
||||||
setFilterSearchTerm(searchTerm);
|
|
||||||
setSelectedFilterIndex(0);
|
|
||||||
|
|
||||||
// Only set anchor position when @ is first detected (search term is empty)
|
|
||||||
if (searchTerm === "") {
|
|
||||||
const pos = getCursorPosition(e.target);
|
|
||||||
setAnchorPosition(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFilterDropdownOpen) {
|
|
||||||
loadAvailableFilters();
|
|
||||||
setIsFilterDropdownOpen(true);
|
|
||||||
}
|
|
||||||
} else if (isFilterDropdownOpen) {
|
|
||||||
// Close dropdown if @ is no longer present
|
|
||||||
console.log("Closing dropdown - no @ found");
|
|
||||||
setIsFilterDropdownOpen(false);
|
|
||||||
setFilterSearchTerm("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset dismissed flag when user moves to a different word
|
|
||||||
if (dropdownDismissed && !lastWord.startsWith("@")) {
|
|
||||||
setDropdownDismissed(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAtClick = () => {
|
|
||||||
if (!isFilterDropdownOpen) {
|
|
||||||
loadAvailableFilters();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Debug header - only show in debug mode */}
|
{/* Debug header - only show in debug mode */}
|
||||||
|
|
@ -1377,23 +1113,46 @@ function ChatPage() {
|
||||||
loading={loading}
|
loading={loading}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
selectedFilter={selectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
isFilterDropdownOpen={isFilterDropdownOpen}
|
|
||||||
availableFilters={availableFilters}
|
|
||||||
filterSearchTerm={filterSearchTerm}
|
|
||||||
selectedFilterIndex={selectedFilterIndex}
|
|
||||||
anchorPosition={anchorPosition}
|
|
||||||
parsedFilterData={parsedFilterData}
|
parsedFilterData={parsedFilterData}
|
||||||
uploadedFile={uploadedFile}
|
uploadedFile={uploadedFile}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onChange={onChange}
|
onChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={(e) => {
|
||||||
|
// Handle backspace for filter clearing
|
||||||
|
if (
|
||||||
|
e.key === "Backspace" &&
|
||||||
|
selectedFilter &&
|
||||||
|
input.trim() === ""
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isFilterHighlighted) {
|
||||||
|
// Second backspace - remove the filter
|
||||||
|
setConversationFilter(null);
|
||||||
|
setIsFilterHighlighted(false);
|
||||||
|
} else {
|
||||||
|
// First backspace - highlight the filter
|
||||||
|
setIsFilterHighlighted(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key for form submission
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.trim() && !loading) {
|
||||||
|
// Trigger form submission by finding the form and calling submit
|
||||||
|
const form = e.currentTarget.closest("form");
|
||||||
|
if (form) {
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
onFilterSelect={handleFilterSelect}
|
onFilterSelect={handleFilterSelect}
|
||||||
onAtClick={onAtClick}
|
|
||||||
onFilePickerClick={handleFilePickerClick}
|
onFilePickerClick={handleFilePickerClick}
|
||||||
onFileSelected={setUploadedFile}
|
onFileSelected={setUploadedFile}
|
||||||
setSelectedFilter={setConversationFilter}
|
setSelectedFilter={setConversationFilter}
|
||||||
setIsFilterHighlighted={setIsFilterHighlighted}
|
setIsFilterHighlighted={setIsFilterHighlighted}
|
||||||
setIsFilterDropdownOpen={setIsFilterDropdownOpen}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue