diff --git a/Makefile b/Makefile index fe76467a..e8b08a1b 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,14 @@ infra: @echo " OpenSearch: http://localhost:9200" @echo " Dashboards: http://localhost:5601" +infra-cpu: + @echo "🔧 Starting infrastructure services only..." + docker-compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow + @echo "✅ Infrastructure services started!" + @echo " Langflow: http://localhost:7860" + @echo " OpenSearch: http://localhost:9200" + @echo " Dashboards: http://localhost:5601" + # Container management stop: @echo "🛑 Stopping all containers..." diff --git a/frontend/components/knowledge-actions-dropdown.tsx b/frontend/components/knowledge-actions-dropdown.tsx new file mode 100644 index 00000000..ecf77e22 --- /dev/null +++ b/frontend/components/knowledge-actions-dropdown.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { EllipsisVertical } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "./ui/button"; + +export function KnowledgeActionsDropdown() { + return ( + + + + + + Delete + + + ); +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 67fb6727..1eea9079 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -35,7 +35,7 @@ const Input = React.forwardRef( /> =0.4.0" } }, + "node_modules/ag-charts-types": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz", + "integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==" + }, + "node_modules/ag-grid-community": { + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz", + "integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==", + "dependencies": { + "ag-charts-types": "12.2.0" + } + }, + "node_modules/ag-grid-react": { + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz", + "integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==", + "dependencies": { + "ag-grid-community": "34.2.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 185e3866..09dac477 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.86.0", + "ag-grid-community": "^34.2.0", + "ag-grid-react": "^34.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index d72942d7..2116f4d1 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -7,35 +7,28 @@ import { HardDrive, Loader2, Search, - Trash2, - Edit, - RefreshCw, } from "lucide-react"; +import { AgGridReact, CustomCellRendererProps } from "ag-grid-react"; import { type FormEvent, useCallback, useEffect, - useRef, useState, + useRef, } from "react"; import { SiGoogledrive } from "react-icons/si"; import { TbBrandOnedrive } from "react-icons/tb"; -import { FaEllipsisVertical } from "react-icons/fa6"; -import { useQueryClient } from "@tanstack/react-query"; +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 { Checkbox } from "@/components/ui/checkbox"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useTask } from "@/contexts/task-context"; import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; -import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; +import { ColDef, RowClickedEvent } from "ag-grid-community"; +import "@/components/AgGrid/registerAgGridModules"; +import "@/components/AgGrid/agGridStyles.css"; +import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown"; // Function to get the appropriate icon for a connector type function getSourceIcon(connectorType?: string) { @@ -59,32 +52,12 @@ function SearchPage() { const [query, setQuery] = useState(""); const [queryInputText, setQueryInputText] = useState(""); const [selectedFile, setSelectedFile] = useState(null); - const [openDropdown, setOpenDropdown] = useState(null); - // Delete state - const [selectedDocuments, setSelectedDocuments] = useState>( - new Set() - ); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ - type: "bulk" | "single"; - filenames: string[]; - } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); - const [refreshTrigger, setRefreshTrigger] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars - - const queryClient = useQueryClient(); - - const { data = [], isFetching } = useGetSearchQuery(query, parsedFilterData); - - // Use refs to access current values in event handler - const currentQueryRef = useRef(query); - const currentParsedFilterRef = useRef(parsedFilterData); - const currentDataRef = useRef(data); - - currentQueryRef.current = query; - currentParsedFilterRef.current = parsedFilterData; - currentDataRef.current = data; + const { + data = [], + isFetching, + refetch: refetchSearch, + } = useGetSearchQuery(query, parsedFilterData); // Update query when global filter changes useEffect(() => { @@ -93,205 +66,103 @@ function SearchPage() { } }, [parsedFilterData]); - // Listen for knowledge updates from other sources (uploads, ingestion, etc.) - useEffect(() => { - const handleKnowledgeUpdate = async () => { - // Get the current effective query that matches what the UI is showing (using refs to get current values) - const currentEffectiveQuery = - currentQueryRef.current || currentParsedFilterRef.current?.query || "*"; - - // Be very aggressive about clearing the cache and refetching - queryClient.removeQueries({ - queryKey: ["search"], - exact: false, - }); - - // Force an immediate refetch of the current query - await queryClient.refetchQueries({ - queryKey: ["search", currentEffectiveQuery], - exact: true, - }); - - // Also trigger a state change to force re-render (backup plan) - setRefreshTrigger(prev => prev + 1); - }; - - window.addEventListener("knowledgeUpdated", handleKnowledgeUpdate); - - return () => { - window.removeEventListener("knowledgeUpdated", handleKnowledgeUpdate); - }; - }, [queryClient]); // Only depend on queryClient which is stable - const handleSearch = useCallback( (e?: FormEvent) => { if (e) e.preventDefault(); if (query.trim() === queryInputText.trim()) { - // If same query, invalidate cache to ensure fresh data - const effectiveQuery = - currentQueryRef.current || - currentParsedFilterRef.current?.query || - "*"; - queryClient.invalidateQueries({ - queryKey: ["search", effectiveQuery], - exact: true, - }); + refetchSearch(); return; } setQuery(queryInputText); }, - [queryInputText, query, queryClient] + [queryInputText, refetchSearch, query] ); - // Delete handlers - const handleBulkDelete = () => { - const filenames = Array.from(selectedDocuments); - setDeleteTarget({ - type: "bulk", - filenames, - }); - setDeleteDialogOpen(true); - }; - - const handleSingleDelete = (filename: string) => { - setDeleteTarget({ - type: "single", - filenames: [filename], - }); - setDeleteDialogOpen(true); - setOpenDropdown(null); // Close the dropdown - }; - - const handleRename = (filename: string) => { - setOpenDropdown(null); // Close the dropdown - alert(`Rename functionality not implemented yet for ${filename}`); - }; - - const handleSync = (filename: string) => { - setOpenDropdown(null); // Close the dropdown - alert(`Sync functionality not implemented yet for ${filename}`); - }; - - const performDelete = async () => { - if (!deleteTarget) return; - - setIsDeleting(true); - - // Use the same effective query normalization as the search hook (using current refs) - const effectiveQuery = - currentQueryRef.current || currentParsedFilterRef.current?.query || "*"; - - // Store the original data before optimistic update - const originalData = - queryClient.getQueryData(["search", effectiveQuery]) || []; - const filesToDelete = new Set(deleteTarget.filenames); - - // Optimistically update the UI - immediately filter out the files being deleted - queryClient.setQueryData(["search", effectiveQuery], oldData => { - if (!oldData) return []; - return oldData.filter(file => !filesToDelete.has(file.filename)); - }); - - try { - // Delete documents by filename (since we only have filenames, not document IDs) - const filenames = deleteTarget.filenames; - const results = []; - let successCount = 0; - const failedDeletes: string[] = []; - - for (const filename of filenames) { - try { - const response = await fetch("/api/documents/delete-by-filename", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - filename: filename, - }), - }); - - if (response.ok) { - const result = await response.json(); - results.push({ filename, success: true, ...result }); - successCount++; - } else { - const error = await response.json(); - results.push({ filename, success: false, error: error.error }); - failedDeletes.push(filename); - } - } catch (error) { - results.push({ filename, success: false, error: String(error) }); - failedDeletes.push(filename); - } - } - - // Log results for user feedback - console.info(`Deleted ${successCount}/${filenames.length} documents`); - - // If any deletes failed, restore the failed items to the UI - if (failedDeletes.length > 0) { - console.warn( - `Failed to delete ${failedDeletes.length} documents:`, - failedDeletes - ); - - // Restore failed items using the original data - queryClient.setQueryData( - ["search", effectiveQuery], - currentData => { - if (!currentData) return originalData; - - const failedSet = new Set(failedDeletes); - const restoredItems = originalData.filter(file => - failedSet.has(file.filename) - ); - - // Merge current optimistically updated data with restored failed items - return [...currentData, ...restoredItems]; - } - ); - } - // If all deletes succeeded, keep the optimistic update - no need to refetch - - // Clear selection - setSelectedDocuments(new Set()); - } catch (error) { - console.error("Delete failed:", error); - // Restore the original data on complete failure - queryClient.setQueryData( - ["search", effectiveQuery], - originalData - ); - // TODO: Add toast notification for error - } finally { - setIsDeleting(false); - setDeleteDialogOpen(false); - setDeleteTarget(null); - } - }; - - const toggleDocumentSelection = (filename: string) => { - const newSelection = new Set(selectedDocuments); - if (newSelection.has(filename)) { - newSelection.delete(filename); - } else { - newSelection.add(filename); - } - setSelectedDocuments(newSelection); - }; - - const selectAllDocuments = () => { - const allFilenames = new Set(fileResults.map(file => file.filename)); - setSelectedDocuments(allFilenames); - }; - - const clearSelection = () => { - setSelectedDocuments(new Set()); - }; - const fileResults = data as File[]; + const gridRef = useRef(null); + + const [columnDefs] = useState[]>([ + { + field: "filename", + headerName: "Source", + cellRenderer: ({ data, value }: CustomCellRendererProps) => { + return ( +
+ {getSourceIcon(data?.connector_type)} + + {value} + +
+ ); + }, + }, + { + field: "size", + headerName: "Size", + valueFormatter: params => + params.value ? `${Math.round(params.value / 1024)} KB` : "-", + }, + { + field: "mimetype", + headerName: "Type", + }, + { + field: "owner", + headerName: "Owner", + valueFormatter: params => + params.value || + params.data?.owner_name || + params.data?.owner_email || + "—", + }, + + { + field: "chunkCount", + headerName: "Chunks", + }, + { + field: "avgScore", + headerName: "Avg score", + cellRenderer: ({ value }: CustomCellRendererProps) => { + return ( + + {value.toFixed(2)} + + ); + }, + }, + { + cellRenderer: () => { + return ; + }, + cellStyle: { + alignItems: "center", + display: "flex", + justifyContent: "center", + padding: 0, + }, + colId: "actions", + filter: false, + maxWidth: 60, + minWidth: 60, + resizable: false, + sortable: false, + initialFlex: 0, + }, + ]); + + const defaultColDef: ColDef = { + cellStyle: () => ({ + display: "flex", + alignItems: "center", + }), + initialFlex: 1, + minWidth: 100, + resizable: false, + suppressMovable: true, + }; + return (
+
+

Project Knowledge

+ +
{/* Search Input Area */} -
-
- - Knowledge Project - - -
+
-
- setQueryInputText(e.target.value)} - placeholder="Search your documents..." - className="flex-2 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 h-12 focus-visible:ring-1 focus-visible:ring-ring" - /> -
+ setQueryInputText(e.target.value)} + placeholder="Search your documents..." + 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" + /> - - -
- - {/* Results Area */} -
-
- {fileResults.length === 0 && !isFetching ? ( -
+ {selectedFile ? ( + // Show chunks for selected file + <> +
+ + + Chunks from {selectedFile} + +
+ {fileResults + .filter(file => file.filename === selectedFile) + .flatMap(file => file.chunks) + .map((chunk, index) => ( +
+
+
+ + + {chunk.filename} + +
+ + {chunk.score.toFixed(2)} + +
+
+ {chunk.mimetype} • Page {chunk.page} +
+

+ {chunk.text} +

+
+ ))} + + ) : ( + ) => { + setSelectedFile(params.data?.filename ?? ""); + }} + noRowsOverlayComponent={() => ( +

No documents found @@ -372,284 +272,10 @@ function SearchPage() { Try adjusting your search terms

- ) : ( -
- {/* Results Count and Bulk Actions */} -
-
- {fileResults.length} file - {fileResults.length !== 1 ? "s" : ""} found -
-
- - {/* Results Display */} -
- {selectedFile ? ( - // Show chunks for selected file - <> -
- - - Chunks from {selectedFile} - -
- {fileResults - .filter(file => file.filename === selectedFile) - .flatMap(file => file.chunks) - .map((chunk, index) => ( -
-
-
- - - {chunk.filename} - -
- - {chunk.score.toFixed(2)} - -
-
- {chunk.mimetype} • Page {chunk.page} -
-

- {chunk.text} -

-
- ))} - - ) : ( - // Show files table -
- - - - - - - - - - - - - - {fileResults.map(file => ( - - - - - - - - - - - ))} - -
- 0 && - selectedDocuments.size === - new Set(fileResults.map(f => f.filename)) - .size - } - onCheckedChange={checked => { - if (checked) { - selectAllDocuments(); - } else { - clearSelection(); - } - }} - onClick={e => e.stopPropagation()} - /> - - Source - - Type - - Size - - Chunks - - Score - -
-
- toggleDocumentSelection(file.filename) - } - > - {selectedDocuments.has(file.filename) ? ( - - toggleDocumentSelection(file.filename) - } - onClick={e => e.stopPropagation()} - /> - ) : ( - <> -
- {getSourceIcon(file.connector_type)} -
-
- - toggleDocumentSelection( - file.filename - ) - } - onClick={e => e.stopPropagation()} - /> -
- - )} -
-
setSelectedFile(file.filename)} - > -
- - {file.filename} - -
-
setSelectedFile(file.filename)} - > - - {file.mimetype} - - setSelectedFile(file.filename)} - > - {file.size - ? `${Math.round(file.size / 1024)} KB` - : "—"} - setSelectedFile(file.filename)} - > - {file.chunkCount} - setSelectedFile(file.filename)} - > - - {file.avgScore.toFixed(2)} - - - - setOpenDropdown(open ? file.filename : null) - } - > - - - - -
- - - -
-
-
-
-
- )} -
-
)} -
-
+ /> + )}
- - {/* Delete Confirmation Dialog */} -
); } diff --git a/frontend/src/components/AgGrid/agGridStyles.css b/frontend/src/components/AgGrid/agGridStyles.css new file mode 100644 index 00000000..b595e18c --- /dev/null +++ b/frontend/src/components/AgGrid/agGridStyles.css @@ -0,0 +1,21 @@ +body { + --ag-text-color: hsl(var(--muted-foreground)); + --ag-background-color: hsl(var(--background)); + --ag-header-background-color: hsl(var(--background)); + --ag-header-text-color: hsl(var(--muted-foreground)); + --ag-header-column-resize-handle-color: hsl(var(--border)); + --ag-header-row-border: hsl(var(--border)); + --ag-header-font-weight: var(--font-medium); + --ag-row-border: undefined; + --ag-row-hover-color: hsl(var(--muted)); + --ag-wrapper-border: none; + --ag-font-family: var(--font-sans); + + .ag-header { + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 0.5rem; + } + .ag-row { + cursor: pointer; + } +} diff --git a/frontend/src/components/AgGrid/registerAgGridModules.ts b/frontend/src/components/AgGrid/registerAgGridModules.ts new file mode 100644 index 00000000..da2c5280 --- /dev/null +++ b/frontend/src/components/AgGrid/registerAgGridModules.ts @@ -0,0 +1,33 @@ +import { + ModuleRegistry, + ValidationModule, + ColumnAutoSizeModule, + ColumnApiModule, + PaginationModule, + CellStyleModule, + QuickFilterModule, + ClientSideRowModelModule, + TextFilterModule, + DateFilterModule, + EventApiModule, + GridStateModule, + } from 'ag-grid-community'; + + // Importing necessary modules from ag-grid-community + // https://www.ag-grid.com/javascript-data-grid/modules/#selecting-modules + + ModuleRegistry.registerModules([ + ColumnAutoSizeModule, + ColumnApiModule, + PaginationModule, + CellStyleModule, + QuickFilterModule, + ClientSideRowModelModule, + TextFilterModule, + DateFilterModule, + EventApiModule, + GridStateModule, + // The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development. + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), + ]); + \ No newline at end of file