diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index 6a831db6..88b93671 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -1,325 +1,109 @@ -"use client" +"use client"; -import { useState, useEffect, useCallback, useRef } from "react" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Search, Loader2, FileText, HardDrive, Building2, Cloud } from "lucide-react" -import { TbBrandOnedrive } from "react-icons/tb" -import { SiGoogledrive } from "react-icons/si" -import { ProtectedRoute } from "@/components/protected-route" -import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context" -import { useTask } from "@/contexts/task-context" -import { KnowledgeDropdown } from "@/components/knowledge-dropdown" - - -interface ChunkResult { - filename: string - mimetype: string - page: number - text: string - score: number - source_url?: string - owner?: string - owner_name?: string - owner_email?: string - file_size?: number - connector_type?: string -} - -interface FileResult { - filename: string - mimetype: string - chunkCount: number - avgScore: number - source_url?: string - owner?: string - owner_name?: string - owner_email?: string - lastModified?: string - size?: number - connector_type?: string -} - -interface SearchResponse { - results: ChunkResult[] - files?: FileResult[] - error?: string - total?: number - aggregations?: { - data_sources?: { buckets?: Array<{ key: string | number; doc_count: number }> } - document_types?: { buckets?: Array<{ key: string | number; doc_count: number }> } - owners?: { buckets?: Array<{ key: string | number; doc_count: number }> } - connector_types?: { buckets?: Array<{ key: string | number; doc_count: number }> } - } -} +import { + Building2, + Cloud, + FileText, + HardDrive, + Loader2, + Search, +} from "lucide-react"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { SiGoogledrive } from "react-icons/si"; +import { TbBrandOnedrive } from "react-icons/tb"; +import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; +import { ProtectedRoute } from "@/components/protected-route"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useTask } from "@/contexts/task-context"; +import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; // Function to get the appropriate icon for a connector type function getSourceIcon(connectorType?: string) { switch (connectorType) { - case 'google_drive': - return - case 'onedrive': - return - case 'sharepoint': - return - case 's3': - return - case 'local': + case "google_drive": + return ; + case "onedrive": + return ; + case "sharepoint": + return ; + case "s3": + return ; default: - return + return ; } } function SearchPage() { - const { isMenuOpen } = useTask() - const { parsedFilterData, isPanelOpen } = useKnowledgeFilter() - const [query, setQuery] = useState("*") - const [loading, setLoading] = useState(false) - const [chunkResults, setChunkResults] = useState([]) - const [fileResults, setFileResults] = useState([]) - const [selectedFile, setSelectedFile] = useState(null) - const [searchPerformed, setSearchPerformed] = useState(false) - const prevFilterDataRef = useRef("") + const { isMenuOpen } = useTask(); + const { parsedFilterData, isPanelOpen } = useKnowledgeFilter(); + const [query, setQuery] = useState(""); + const [queryInputText, setQueryInputText] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); - const handleSearch = useCallback(async (e?: React.FormEvent) => { - if (e) e.preventDefault() - if (!query.trim()) return - - setLoading(true) - setSearchPerformed(false) - - try { - // Build search payload with global filter data - interface SearchPayload { - query: string; - limit: number; - scoreThreshold: number; - filters?: { - data_sources?: string[]; - document_types?: string[]; - owners?: string[]; - connector_types?: string[]; - }; - } - - const searchPayload: SearchPayload = { - query, - limit: parsedFilterData?.limit || (query.trim() === "*" ? 10000 : 10), // Maximum allowed limit for wildcard searches - scoreThreshold: parsedFilterData?.scoreThreshold || 0 - } - - // Debug logging for wildcard searches - if (query.trim() === "*") { - console.log("Wildcard search - parsedFilterData:", parsedFilterData) - } - - // Add filters from global context if available and not wildcards - if (parsedFilterData?.filters) { - const filters = parsedFilterData.filters - - // Only include filters if they're not wildcards (not "*") - const hasSpecificFilters = - !filters.data_sources.includes("*") || - !filters.document_types.includes("*") || - !filters.owners.includes("*") || - (filters.connector_types && !filters.connector_types.includes("*")) - - if (hasSpecificFilters) { - const processedFilters: SearchPayload['filters'] = {} - - // Only add filter arrays that don't contain wildcards - if (!filters.data_sources.includes("*")) { - processedFilters.data_sources = filters.data_sources - } - if (!filters.document_types.includes("*")) { - processedFilters.document_types = filters.document_types - } - if (!filters.owners.includes("*")) { - processedFilters.owners = filters.owners - } - if (filters.connector_types && !filters.connector_types.includes("*")) { - processedFilters.connector_types = filters.connector_types - } - - // Only add filters object if it has any actual filters - if (Object.keys(processedFilters).length > 0) { - searchPayload.filters = processedFilters - } - } - // If all filters are wildcards, omit the filters object entirely - } - - const response = await fetch("/api/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(searchPayload), - }) - - const result: SearchResponse = await response.json() - - if (response.ok) { - const chunks = result.results || [] - - // Debug logging for wildcard searches - if (query.trim() === "*") { - console.log("Wildcard search results:", { - chunks: chunks.length, - totalFromBackend: result.total, - searchPayload, - firstChunk: chunks[0] - }) - } - - setChunkResults(chunks) - - // Group chunks by filename to create file results - const fileMap = new Map() - - chunks.forEach(chunk => { - const existing = fileMap.get(chunk.filename) - if (existing) { - existing.chunks.push(chunk) - existing.totalScore += chunk.score - } else { - fileMap.set(chunk.filename, { - filename: chunk.filename, - mimetype: chunk.mimetype, - chunks: [chunk], - totalScore: chunk.score, - source_url: chunk.source_url, - owner: chunk.owner, - owner_name: chunk.owner_name, - owner_email: chunk.owner_email, - file_size: chunk.file_size, - connector_type: chunk.connector_type - }) - } - }) - - const files: FileResult[] = Array.from(fileMap.values()).map(file => ({ - filename: file.filename, - mimetype: file.mimetype, - chunkCount: file.chunks.length, - avgScore: file.totalScore / file.chunks.length, - source_url: file.source_url, - owner: file.owner, - owner_name: file.owner_name, - owner_email: file.owner_email, - size: file.file_size, - connector_type: file.connector_type - })) - - setFileResults(files) - setSearchPerformed(true) - } else { - console.error("Search failed:", result.error) - setChunkResults([]) - setFileResults([]) - setSearchPerformed(true) - } - } catch (error) { - console.error("Search error:", error) - setChunkResults([]) - setFileResults([]) - setSearchPerformed(true) - } finally { - setLoading(false) - } - }, [query, parsedFilterData]) + const { + data = [], + isFetching, + refetch: refetchSearch, + } = useGetSearchQuery(query, parsedFilterData); // Update query when global filter changes useEffect(() => { if (parsedFilterData?.query) { - setQuery(parsedFilterData.query) + setQueryInputText(parsedFilterData.query); } - }, [parsedFilterData]) - - // Auto-refresh search when filter changes (but only if search was already performed) - useEffect(() => { - if (!parsedFilterData) return - - // Create a stable string representation of the filter data for comparison - const currentFilterString = JSON.stringify({ - filters: parsedFilterData.filters, - limit: parsedFilterData.limit, - scoreThreshold: parsedFilterData.scoreThreshold - }) - - // Only trigger search if filter data actually changed and we've done a search before - if (prevFilterDataRef.current !== "" && - prevFilterDataRef.current !== currentFilterString && - searchPerformed && - query.trim()) { - - console.log("Filter changed, auto-refreshing search") - handleSearch() - } - - // Update the ref with current filter data - prevFilterDataRef.current = currentFilterString - }, [parsedFilterData, searchPerformed, query, handleSearch]) - - // Auto-search on mount with "*" - useEffect(() => { - // Only trigger initial search on mount when query is "*" - handleSearch() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // Only run once on mount - ignore handleSearch dependency - - // Listen for knowledge updates and refresh search - useEffect(() => { - const handleKnowledgeUpdate = () => { - console.log("Knowledge updated, refreshing search") - handleSearch() - } - - window.addEventListener('knowledgeUpdated', handleKnowledgeUpdate) - return () => window.removeEventListener('knowledgeUpdated', handleKnowledgeUpdate) - }, [handleSearch]) - + }, [parsedFilterData]); + const handleSearch = useCallback( + (e?: FormEvent) => { + if (e) e.preventDefault(); + if (query.trim() === queryInputText.trim()) { + refetchSearch(); + return; + } + setQuery(queryInputText); + }, + [queryInputText, refetchSearch, query], + ); + const fileResults = data as File[]; return ( -
+
{/* Search Input Area */}
setQueryInputText(e.target.value)} placeholder="Search your documents..." - value={query} - onChange={(e) => setQuery(e.target.value)} className="flex-1 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 h-12 focus-visible:ring-1 focus-visible:ring-ring" /> + + Chunks from {selectedFile} + +
+ {fileResults + .filter((file) => file.filename === selectedFile) + .flatMap((file) => file.chunks) + .map((chunk, index) => ( +
- ← Back to files - - - Chunks from {selectedFile} - -
- {chunkResults - .filter(chunk => chunk.filename === selectedFile) - .map((chunk, index) => ( -
- {chunk.filename} + + {chunk.filename} +
{chunk.score.toFixed(2)} @@ -389,67 +184,87 @@ function SearchPage() {

))} - - ) : ( - // Show files table -
- - - - - - - - - - - - - {fileResults.map((file, index) => ( - setSelectedFile(file.filename)} - > - - - - - + + + + + + + ))} + +
SourceTypeSizeMatching chunksAverage scoreOwner
-
- {getSourceIcon(file.connector_type)} - - {file.filename} - -
-
- {file.mimetype} - - {file.size ? `${Math.round(file.size / 1024)} KB` : '—'} - - {file.chunkCount} - - - {file.avgScore.toFixed(2)} + + ) : ( + // Show files table +
+ + + + + + + + + + + + + {fileResults.map((file) => ( + setSelectedFile(file.filename)} + > + - - - ))} - -
+ Source + + Type + + Size + + Matching chunks + + Average score + + Owner +
+
+ {getSourceIcon(file.connector_type)} + + {file.filename} -
- {file.owner_name || file.owner || '—'} -
-
- )} + +
+ {file.mimetype} + + {file.size + ? `${Math.round(file.size / 1024)} KB` + : "—"} + + {file.chunkCount} + + + {file.avgScore.toFixed(2)} + + + {file.owner_name || file.owner || "—"} +
+
+ )}
- +
)}
- ) + ); } export default function ProtectedSearchPage() { @@ -457,5 +272,5 @@ export default function ProtectedSearchPage() { - ) + ); }