diff --git a/frontend/src/app/chat/components/chat-input.tsx b/frontend/src/app/chat/components/chat-input.tsx index 7a4bee59..32fec72d 100644 --- a/frontend/src/app/chat/components/chat-input.tsx +++ b/frontend/src/app/chat/components/chat-input.tsx @@ -1,8 +1,7 @@ -import { ArrowRight, Check, Funnel, Loader2, Plus, X } from "lucide-react"; +import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; import { forwardRef, useImperativeHandle, useRef } from "react"; import TextareaAutosize from "react-textarea-autosize"; import type { FilterColor } from "@/components/filter-icon-popover"; -import { filterAccentClasses } from "@/components/knowledge-filter-panel"; import { Button } from "@/components/ui/button"; import { Popover, @@ -10,6 +9,9 @@ import { PopoverContent, } from "@/components/ui/popover"; import type { KnowledgeFilterData } from "../types"; +import { useState } from "react"; +import { FilePreview } from "./file-preview"; +import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; export interface ChatInputHandle { focusInput: () => void; @@ -26,19 +28,18 @@ interface ChatInputProps { filterSearchTerm: string; selectedFilterIndex: number; anchorPosition: { x: number; y: number } | null; - textareaHeight: number; parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; onSubmit: (e: React.FormEvent) => void; onChange: (e: React.ChangeEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onHeightChange: (height: number) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void; onAtClick: () => void; - onFilePickerChange: (e: React.ChangeEvent) => void; onFilePickerClick: () => void; setSelectedFilter: (filter: KnowledgeFilterData | null) => void; setIsFilterHighlighted: (highlighted: boolean) => void; setIsFilterDropdownOpen: (open: boolean) => void; + onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( @@ -53,24 +54,24 @@ export const ChatInput = forwardRef( filterSearchTerm, selectedFilterIndex, anchorPosition, - textareaHeight, parsedFilterData, + uploadedFile, onSubmit, onChange, onKeyDown, - onHeightChange, onFilterSelect, onAtClick, - onFilePickerChange, onFilePickerClick, setSelectedFilter, setIsFilterHighlighted, setIsFilterDropdownOpen, + onFileSelected, }, ref, ) => { const inputRef = useRef(null); const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); useImperativeHandle(ref, () => ({ focusInput: () => { @@ -80,90 +81,143 @@ export const ChatInput = forwardRef( fileInputRef.current?.click(); }, })); + + const handleFilePickerChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; return (
-
- {selectedFilter ? ( - - {selectedFilter.name} - - - ) : ( - - )} -
- + )} + + {/* 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} + 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); + }} + /> + ) : ( + + ) + )} +
+ + +
+
- -
diff --git a/frontend/src/app/chat/components/file-preview.tsx b/frontend/src/app/chat/components/file-preview.tsx new file mode 100644 index 00000000..ad6876c2 --- /dev/null +++ b/frontend/src/app/chat/components/file-preview.tsx @@ -0,0 +1,67 @@ +import { X } from "lucide-react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; + +interface FilePreviewProps { + uploadedFile: File; + onClear: () => void; +} + +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; +}; + +const getFilePreviewUrl = (file: File): string => { + if (file.type.startsWith("image/")) { + return URL.createObjectURL(file); + } + return ""; +}; + +export const FilePreview = ({ uploadedFile, onClear }: FilePreviewProps) => { + return ( +
+ {/* File Image Preview */} +
+ {getFilePreviewUrl(uploadedFile) ? ( + File preview + ) : ( +
+ {uploadedFile.name.split(".").pop()?.toUpperCase()} +
+ )} +
+ + {/* File Info */} +
+
+ {uploadedFile.name} +
+
+ {formatFileSize(uploadedFile.size)} +
+
+ + {/* Clear Button */} + +
+ ); +}; diff --git a/frontend/src/app/chat/components/selected-knowledge-filter.tsx b/frontend/src/app/chat/components/selected-knowledge-filter.tsx new file mode 100644 index 00000000..cd97db73 --- /dev/null +++ b/frontend/src/app/chat/components/selected-knowledge-filter.tsx @@ -0,0 +1,33 @@ +import { X } from "lucide-react"; +import type { KnowledgeFilterData } from "../types"; +import { filterAccentClasses } from "@/components/knowledge-filter-panel"; +import type { FilterColor } from "@/components/filter-icon-popover"; + +interface SelectedKnowledgeFilterProps { + selectedFilter: KnowledgeFilterData; + parsedFilterData: { color?: FilterColor } | null; + onClear: () => void; +} + +export const SelectedKnowledgeFilter = ({ + selectedFilter, + parsedFilterData, + onClear, +}: SelectedKnowledgeFilterProps) => { + return ( + + {selectedFilter.name} + + + ); +}; diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index d55eae03..789e000f 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -60,7 +60,6 @@ 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); @@ -71,6 +70,8 @@ function ChatPage() { x: number; y: number; } | null>(null); + const [uploadedFile, setUploadedFile] = useState(null); + const chatInputRef = useRef(null); const { scrollToBottom } = useStickToBottomContext(); @@ -127,7 +128,7 @@ function ChatPage() { // Copy all computed styles to the hidden div for (const style of computedStyle) { - (div.style as any)[style] = computedStyle.getPropertyValue(style); + (div.style as unknown as Record)[style] = computedStyle.getPropertyValue(style); } // Set the div to be hidden but not un-rendered @@ -247,6 +248,7 @@ function ChatPage() { timestamp: new Date(), }; setMessages((prev) => [...prev.slice(0, -1), pollingMessage]); + return null; } else if (response.ok) { // Original flow: Direct response @@ -282,6 +284,8 @@ function ChatPage() { // For existing conversations, do a silent refresh to keep backend in sync refreshConversationsSilent(); } + + return result.response_id; } } else { throw new Error(`Upload failed: ${response.status}`); @@ -304,13 +308,6 @@ function ChatPage() { chatInputRef.current?.clickFileInput(); }; - const handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - handleFileUpload(files[0]); - } - }; - const loadAvailableFilters = async () => { try { const response = await fetch("/api/knowledge-filter/search", { @@ -601,6 +598,7 @@ function ChatPage() { setLoading(true); setIsUploading(true); + setUploadedFile(null); // Clear previous file // Add initial upload message const uploadStartMessage: Message = { @@ -627,6 +625,7 @@ function ChatPage() { }; setMessages((prev) => [...prev.slice(0, -1), uploadMessage]); + setUploadedFile(null); // Clear file after upload // Update the response ID for this endpoint if (result.response_id) { @@ -658,6 +657,7 @@ function ChatPage() { timestamp: new Date(), }; setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + setUploadedFile(null); // Clear file on error }; window.addEventListener( @@ -724,7 +724,7 @@ function ChatPage() { }, ); - const handleSSEStream = async (userMessage: Message) => { + const handleSSEStream = async (userMessage: Message, previousResponseId?: string) => { // Prepare filters const processedFilters = parsedFilterData?.filters ? (() => { @@ -750,10 +750,13 @@ function ChatPage() { })() : undefined; + // Use passed previousResponseId if available, otherwise fall back to state + const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; + // Use the hook to send the message await sendStreamingMessage({ prompt: userMessage.content, - previousResponseId: previousResponseIds[endpoint] || undefined, + previousResponseId: responseIdToUse || undefined, filters: processedFilters, limit: parsedFilterData?.limit ?? 10, scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, @@ -764,7 +767,7 @@ function ChatPage() { }); }; - const handleSendMessage = async (inputMessage: string) => { + const handleSendMessage = async (inputMessage: string, previousResponseId?: string) => { if (!inputMessage.trim() || loading) return; const userMessage: Message = { @@ -784,7 +787,7 @@ function ChatPage() { }); if (asyncMode) { - await handleSSEStream(userMessage); + await handleSSEStream(userMessage, previousResponseId); } else { // Original non-streaming logic try { @@ -891,7 +894,30 @@ function ChatPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - handleSendMessage(input); + + // Check if there's an uploaded file and upload it first + let uploadedResponseId: string | null = null; + if (uploadedFile) { + // Upload the file first + const responseId = await handleFileUpload(uploadedFile); + // Clear the file after upload + setUploadedFile(null); + + // If the upload resulted in a new conversation, store the response ID + if (responseId) { + uploadedResponseId = responseId; + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + } + } + + // Only send message if there's input text + if (input.trim()) { + // Pass the responseId from upload (if any) to handleSendMessage + handleSendMessage(input, uploadedResponseId || undefined); + } }; const toggleFunctionCall = (functionCallId: string) => { @@ -1286,16 +1312,15 @@ function ChatPage() { filterSearchTerm={filterSearchTerm} selectedFilterIndex={selectedFilterIndex} anchorPosition={anchorPosition} - textareaHeight={textareaHeight} parsedFilterData={parsedFilterData} + uploadedFile={uploadedFile} onSubmit={handleSubmit} onChange={onChange} onKeyDown={handleKeyDown} - onHeightChange={(height) => setTextareaHeight(height)} onFilterSelect={handleFilterSelect} onAtClick={onAtClick} - onFilePickerChange={handleFilePickerChange} onFilePickerClick={handleFilePickerClick} + onFileSelected={setUploadedFile} setSelectedFilter={setSelectedFilter} setIsFilterHighlighted={setIsFilterHighlighted} setIsFilterDropdownOpen={setIsFilterDropdownOpen} diff --git a/frontend/src/contexts/knowledge-filter-context.tsx b/frontend/src/contexts/knowledge-filter-context.tsx index eebf355a..4f7ecd59 100644 --- a/frontend/src/contexts/knowledge-filter-context.tsx +++ b/frontend/src/contexts/knowledge-filter-context.tsx @@ -130,7 +130,7 @@ export function KnowledgeFilterProvider({ }, limit: 10, scoreThreshold: 0, - color: "zinc", + color: "amber", icon: "filter", }); setIsPanelOpen(true);