diff --git a/config/config.example.yaml b/config/config.example.yaml deleted file mode 100644 index 410025e7..00000000 --- a/config/config.example.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# OpenRAG Configuration File -provider: - model_provider: "openai" # openai, anthropic, azure, etc. - api_key: "your-api-key" # or use OPENAI_API_KEY env var - -knowledge: - embedding_model: "text-embedding-3-small" - chunk_size: 1000 - chunk_overlap: 200 - ocr: true - picture_descriptions: false - -agent: - llm_model: "gpt-4o-mini" - system_prompt: "You are a helpful AI assistant..." \ No newline at end of file diff --git a/docker-compose-cpu.yml b/docker-compose-cpu.yml index 9b0ff88b..d0de6ce9 100644 --- a/docker-compose-cpu.yml +++ b/docker-compose-cpu.yml @@ -74,7 +74,6 @@ services: - ./documents:/app/documents:Z - ./keys:/app/keys:Z - ./flows:/app/flows:Z - - ./config:/app/config:z openrag-frontend: image: phact/openrag-frontend:${OPENRAG_VERSION:-latest} diff --git a/docker-compose.yml b/docker-compose.yml index 34a5947f..daa921ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,6 @@ services: - ./documents:/app/documents:Z - ./keys:/app/keys:Z - ./flows:/app/flows:z - - ./config:/app/config:z gpus: all openrag-frontend: diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 381ce3f7..c901d8ad 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -11,7 +11,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input hover:bg-muted hover:text-accent-foreground disabled:bg-muted disabled:!border-none", + "border border-border hover:bg-muted hover:text-accent-foreground disabled:bg-muted disabled:!border-none", primary: "border bg-background text-secondary-foreground hover:bg-muted hover:shadow-sm", warning: "bg-warning text-warning-foreground hover:bg-warning/90", diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index d1ec83e7..92c8a189 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef< ref={ref} className={cn( "rounded-xl border border-border bg-card text-card-foreground shadow-sm", - className, + className )} {...props} /> @@ -33,8 +33,8 @@ const CardTitle = React.forwardRef<

diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx index ec42c030..ce79b6ae 100644 --- a/frontend/components/ui/popover.tsx +++ b/frontend/components/ui/popover.tsx @@ -1,31 +1,33 @@ -"use client" +"use client"; -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file +export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }; diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index 66665060..63874553 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -26,7 +26,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-10 w-full items-center justify-between rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted [&>span]:line-clamp-1 disabled:border-none", className )} {...props} @@ -34,7 +34,7 @@ const SelectTrigger = React.forwardRef< {children} {props.disabled ? ( - + ) : ( )} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58d78031..33300bd4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", + "react-textarea-autosize": "^8.5.9", "rehype-mathjax": "^7.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -8473,6 +8474,23 @@ "react": ">= 0.14.0" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -10126,6 +10144,51 @@ } } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc9eb72c..fd996c33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", + "react-textarea-autosize": "^8.5.9", "rehype-mathjax": "^7.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 240bb4d2..01ee43c7 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -3,8 +3,10 @@ import { AtSign, Bot, + Check, ChevronDown, ChevronRight, + Funnel, GitBranch, Loader2, Plus, @@ -15,10 +17,17 @@ import { Zap, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import { filterAccentClasses } from "@/components/knowledge-filter-panel"; import { MarkdownRenderer } from "@/components/markdown-renderer"; import { ProtectedRoute } from "@/components/protected-route"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; import { useAuth } from "@/contexts/auth-context"; import { type EndpointType, useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; @@ -26,7 +35,6 @@ import { useTask } from "@/contexts/task-context"; import { useLoadingStore } from "@/stores/loadingStore"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; import Nudges from "./nudges"; -import { filterAccentClasses } from "@/components/knowledge-filter-panel"; interface Message { role: "user" | "assistant"; @@ -125,16 +133,20 @@ function ChatPage() { const [availableFilters, setAvailableFilters] = useState< KnowledgeFilterData[] >([]); + const [textareaHeight, setTextareaHeight] = useState(40); const [filterSearchTerm, setFilterSearchTerm] = useState(""); const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); const [dropdownDismissed, setDropdownDismissed] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false); const [isForkingInProgress, setIsForkingInProgress] = useState(false); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); const fileInputRef = useRef(null); - const dropdownRef = useRef(null); const streamAbortRef = useRef(null); const streamIdRef = useRef(0); const lastLoadedConversationRef = useRef(null); @@ -146,6 +158,59 @@ function ChatPage() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; + 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 any)[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) => { setEndpoint(newEndpoint); // Clear the conversation when switching endpoints to avoid response ID conflicts @@ -193,7 +258,7 @@ function ChatPage() { "Upload failed with status:", response.status, "Response:", - errorText + errorText, ); throw new Error("Failed to process document"); } @@ -314,13 +379,6 @@ function ChatPage() { } }; - const handleFilterDropdownToggle = () => { - if (!isFilterDropdownOpen) { - loadAvailableFilters(); - } - setIsFilterDropdownOpen(!isFilterDropdownOpen); - }; - const handleFilterSelect = (filter: KnowledgeFilterData | null) => { setSelectedFilter(filter); setIsFilterDropdownOpen(false); @@ -410,7 +468,7 @@ function ChatPage() { console.log( "Loading conversation with", conversationData.messages.length, - "messages" + "messages", ); // Convert backend message format to frontend Message interface const convertedMessages: Message[] = conversationData.messages.map( @@ -538,7 +596,7 @@ function ChatPage() { ) === "string" ? toolCall.function?.arguments || toolCall.arguments : JSON.stringify( - toolCall.function?.arguments || toolCall.arguments + toolCall.function?.arguments || toolCall.arguments, ), result: toolCall.result, status: "completed", @@ -557,7 +615,7 @@ function ChatPage() { } return message; - } + }, ); setMessages(convertedMessages); @@ -646,7 +704,7 @@ function ChatPage() { console.log( "Chat page received file upload error event:", filename, - error + error, ); // Replace the last message with error message @@ -660,64 +718,43 @@ function ChatPage() { window.addEventListener( "fileUploadStart", - handleFileUploadStart as EventListener + handleFileUploadStart as EventListener, ); window.addEventListener( "fileUploaded", - handleFileUploaded as EventListener + handleFileUploaded as EventListener, ); window.addEventListener( "fileUploadComplete", - handleFileUploadComplete as EventListener + handleFileUploadComplete as EventListener, ); window.addEventListener( "fileUploadError", - handleFileUploadError as EventListener + handleFileUploadError as EventListener, ); return () => { window.removeEventListener( "fileUploadStart", - handleFileUploadStart as EventListener + handleFileUploadStart as EventListener, ); window.removeEventListener( "fileUploaded", - handleFileUploaded as EventListener + handleFileUploaded as EventListener, ); window.removeEventListener( "fileUploadComplete", - handleFileUploadComplete as EventListener + handleFileUploadComplete as EventListener, ); window.removeEventListener( "fileUploadError", - handleFileUploadError as EventListener + handleFileUploadError as EventListener, ); }; }, [endpoint, setPreviousResponseIds]); - // Handle click outside to close dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - isFilterDropdownOpen && - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - !inputRef.current?.contains(event.target as Node) - ) { - setIsFilterDropdownOpen(false); - setFilterSearchTerm(""); - setSelectedFilterIndex(0); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isFilterDropdownOpen]); - const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( - previousResponseIds[endpoint] + previousResponseIds[endpoint], ); const handleSSEStream = async (userMessage: Message) => { @@ -822,7 +859,7 @@ function ChatPage() { console.log( "Received chunk:", chunk.type || chunk.object, - chunk + chunk, ); // Extract response ID if present @@ -838,14 +875,14 @@ function ChatPage() { if (chunk.delta.function_call) { console.log( "Function call in delta:", - chunk.delta.function_call + chunk.delta.function_call, ); // Check if this is a new function call if (chunk.delta.function_call.name) { console.log( "New function call:", - chunk.delta.function_call.name + chunk.delta.function_call.name, ); const functionCall: FunctionCall = { name: chunk.delta.function_call.name, @@ -861,7 +898,7 @@ function ChatPage() { else if (chunk.delta.function_call.arguments) { console.log( "Function call arguments delta:", - chunk.delta.function_call.arguments + chunk.delta.function_call.arguments, ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; @@ -873,14 +910,14 @@ function ChatPage() { chunk.delta.function_call.arguments; console.log( "Accumulated arguments:", - lastFunctionCall.argumentsString + lastFunctionCall.argumentsString, ); // Try to parse arguments if they look complete if (lastFunctionCall.argumentsString.includes("}")) { try { const parsed = JSON.parse( - lastFunctionCall.argumentsString + lastFunctionCall.argumentsString, ); lastFunctionCall.arguments = parsed; lastFunctionCall.status = "completed"; @@ -888,7 +925,7 @@ function ChatPage() { } catch (e) { console.log( "Arguments not yet complete or invalid JSON:", - e + e, ); } } @@ -921,7 +958,7 @@ function ChatPage() { else if (toolCall.function.arguments) { console.log( "Tool call arguments delta:", - toolCall.function.arguments + toolCall.function.arguments, ); const lastFunctionCall = currentFunctionCalls[ @@ -935,7 +972,7 @@ function ChatPage() { toolCall.function.arguments; console.log( "Accumulated tool arguments:", - lastFunctionCall.argumentsString + lastFunctionCall.argumentsString, ); // Try to parse arguments if they look complete @@ -944,7 +981,7 @@ function ChatPage() { ) { try { const parsed = JSON.parse( - lastFunctionCall.argumentsString + lastFunctionCall.argumentsString, ); lastFunctionCall.arguments = parsed; lastFunctionCall.status = "completed"; @@ -952,7 +989,7 @@ function ChatPage() { } catch (e) { console.log( "Tool arguments not yet complete or invalid JSON:", - e + e, ); } } @@ -984,7 +1021,7 @@ function ChatPage() { console.log( "Error parsing function call on finish:", fc, - e + e, ); } } @@ -1000,12 +1037,12 @@ function ChatPage() { console.log( "🟢 CREATING function call (added):", chunk.item.id, - chunk.item.tool_name || chunk.item.name + chunk.item.tool_name || chunk.item.name, ); // Try to find an existing pending call to update (created by earlier deltas) let existing = currentFunctionCalls.find( - (fc) => fc.id === chunk.item.id + (fc) => fc.id === chunk.item.id, ); if (!existing) { existing = [...currentFunctionCalls] @@ -1014,7 +1051,7 @@ function ChatPage() { (fc) => fc.status === "pending" && !fc.id && - fc.name === (chunk.item.tool_name || chunk.item.name) + fc.name === (chunk.item.tool_name || chunk.item.name), ); } @@ -1027,7 +1064,7 @@ function ChatPage() { chunk.item.inputs || existing.arguments; console.log( "🟢 UPDATED existing pending function call with id:", - existing.id + existing.id, ); } else { const functionCall: FunctionCall = { @@ -1045,7 +1082,7 @@ function ChatPage() { currentFunctionCalls.map((fc) => ({ id: fc.id, name: fc.name, - })) + })), ); } } @@ -1056,7 +1093,7 @@ function ChatPage() { ) { console.log( "Function args delta (Realtime API):", - chunk.delta + chunk.delta, ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; @@ -1067,7 +1104,7 @@ function ChatPage() { lastFunctionCall.argumentsString += chunk.delta || ""; console.log( "Accumulated arguments (Realtime API):", - lastFunctionCall.argumentsString + lastFunctionCall.argumentsString, ); } } @@ -1078,26 +1115,26 @@ function ChatPage() { ) { console.log( "Function args done (Realtime API):", - chunk.arguments + chunk.arguments, ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; if (lastFunctionCall) { try { lastFunctionCall.arguments = JSON.parse( - chunk.arguments || "{}" + chunk.arguments || "{}", ); lastFunctionCall.status = "completed"; console.log( "Parsed function arguments (Realtime API):", - lastFunctionCall.arguments + lastFunctionCall.arguments, ); } catch (e) { lastFunctionCall.arguments = { raw: chunk.arguments }; lastFunctionCall.status = "error"; console.log( "Error parsing function arguments (Realtime API):", - e + e, ); } } @@ -1111,14 +1148,14 @@ function ChatPage() { console.log( "🔵 UPDATING function call (done):", chunk.item.id, - chunk.item.tool_name || chunk.item.name + chunk.item.tool_name || chunk.item.name, ); console.log( "🔵 Looking for existing function calls:", currentFunctionCalls.map((fc) => ({ id: fc.id, name: fc.name, - })) + })), ); // Find existing function call by ID or name @@ -1126,14 +1163,14 @@ function ChatPage() { (fc) => fc.id === chunk.item.id || fc.name === chunk.item.tool_name || - fc.name === chunk.item.name + fc.name === chunk.item.name, ); if (functionCall) { console.log( "🔵 FOUND existing function call, updating:", functionCall.id, - functionCall.name + functionCall.name, ); // Update existing function call with completion data functionCall.status = @@ -1156,7 +1193,7 @@ function ChatPage() { "🔴 WARNING: Could not find existing function call to update:", chunk.item.id, chunk.item.tool_name, - chunk.item.name + chunk.item.name, ); } } @@ -1177,7 +1214,7 @@ function ChatPage() { fc.name === chunk.item.name || fc.name === chunk.item.type || fc.name.includes(chunk.item.type.replace("_call", "")) || - chunk.item.type.includes(fc.name) + chunk.item.type.includes(fc.name), ); if (functionCall) { @@ -1221,12 +1258,12 @@ function ChatPage() { "🟡 CREATING tool call (added):", chunk.item.id, chunk.item.tool_name || chunk.item.name, - chunk.item.type + chunk.item.type, ); // Dedupe by id or pending with same name let existing = currentFunctionCalls.find( - (fc) => fc.id === chunk.item.id + (fc) => fc.id === chunk.item.id, ); if (!existing) { existing = [...currentFunctionCalls] @@ -1238,7 +1275,7 @@ function ChatPage() { fc.name === (chunk.item.tool_name || chunk.item.name || - chunk.item.type) + chunk.item.type), ); } @@ -1254,7 +1291,7 @@ function ChatPage() { chunk.item.inputs || existing.arguments; console.log( "🟡 UPDATED existing pending tool call with id:", - existing.id + existing.id, ); } else { const functionCall = { @@ -1275,7 +1312,7 @@ function ChatPage() { id: fc.id, name: fc.name, type: fc.type, - })) + })), ); } } @@ -1553,7 +1590,7 @@ function ChatPage() { const handleForkConversation = ( messageIndex: number, - event?: React.MouseEvent + event?: React.MouseEvent, ) => { // Prevent any default behavior and stop event propagation if (event) { @@ -1618,7 +1655,7 @@ function ChatPage() { const renderFunctionCalls = ( functionCalls: FunctionCall[], - messageIndex?: number + messageIndex?: number, ) => { if (!functionCalls || functionCalls.length === 0) return null; @@ -1851,6 +1888,162 @@ function ChatPage() { handleSendMessage(suggestion); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle backspace for filter clearing + if (e.key === "Backspace" && selectedFilter && input.trim() === "") { + e.preventDefault(); + + if (isFilterHighlighted) { + // Second backspace - remove the filter + setSelectedFilter(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 + 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 (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) => { + 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 (
- + {user?.name ? ( user.name.charAt(0).toUpperCase() @@ -1967,7 +2164,7 @@ function ChatPage() {
{renderFunctionCalls( message.functionCalls || [], - index + index, )}
@@ -1996,7 +2193,7 @@ function ChatPage() {
{renderFunctionCalls( streamingMessage.functionCalls, - messages.length + messages.length, )}
)} -