diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index 3df72944..39aefa55 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -7,6 +7,8 @@ import { HardDrive, Loader2, Search, + Trash2, + X, } from "lucide-react"; import { AgGridReact, CustomCellRendererProps } from "ag-grid-react"; import { @@ -29,6 +31,9 @@ import { ColDef, RowClickedEvent } from "ag-grid-community"; import "@/components/AgGrid/registerAgGridModules"; import "@/components/AgGrid/agGridStyles.css"; import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown"; +import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog"; +import { useDeleteDocument } from "../api/mutations/useDeleteDocument"; +import { toast } from "sonner"; // Function to get the appropriate icon for a connector type function getSourceIcon(connectorType?: string) { @@ -52,6 +57,10 @@ function SearchPage() { const [query, setQuery] = useState(""); const [queryInputText, setQueryInputText] = useState(""); const [selectedFile, setSelectedFile] = useState(null); + const [selectedRows, setSelectedRows] = useState([]); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const deleteDocumentMutation = useDeleteDocument(); const { data = [], @@ -86,9 +95,11 @@ function SearchPage() { { field: "filename", headerName: "Source", + checkboxSelection: true, + headerCheckboxSelection: true, cellRenderer: ({ data, value }: CustomCellRendererProps) => { return ( -
+
{getSourceIcon(data?.connector_type)} {value} @@ -96,6 +107,10 @@ function SearchPage() {
); }, + cellStyle: { + display: "flex", + alignItems: "center", + }, }, { field: "size", @@ -163,6 +178,52 @@ function SearchPage() { suppressMovable: true, }; + const onSelectionChanged = useCallback(() => { + if (gridRef.current) { + const selectedNodes = gridRef.current.api.getSelectedRows(); + setSelectedRows(selectedNodes); + } + }, []); + + const handleBulkDelete = async () => { + if (selectedRows.length === 0) return; + + try { + // Delete each file individually since the API expects one filename at a time + const deletePromises = selectedRows.map(row => + deleteDocumentMutation.mutateAsync({ filename: row.filename }) + ); + + await Promise.all(deletePromises); + + toast.success( + `Successfully deleted ${selectedRows.length} document${ + selectedRows.length > 1 ? "s" : "" + }` + ); + setSelectedRows([]); + setShowBulkDeleteDialog(false); + + // Clear selection in the grid + if (gridRef.current) { + gridRef.current.api.deselectAll(); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete some documents" + ); + } + }; + + const clearSelection = useCallback(() => { + setSelectedRows([]); + if (gridRef.current) { + gridRef.current.api.deselectAll(); + } + }, []); + return (
Project Knowledge
+ + {/* Bulk Actions Bar */} + {selectedRows.length > 0 && ( +
+
+ + {selectedRows.length} document + {selectedRows.length > 1 ? "s" : ""} selected + +
+
+ + +
+
+ )} {/* Search Input Area */}
@@ -259,8 +347,15 @@ function SearchPage() { loading={isFetching} ref={gridRef} rowData={fileResults} + rowSelection="multiple" + suppressRowClickSelection={true} + getRowId={params => params.data.filename} + onSelectionChanged={onSelectionChanged} onRowClicked={(params: RowClickedEvent) => { - setSelectedFile(params.data?.filename ?? ""); + // Only navigate to chunks if no rows are selected and not clicking on checkbox + if (selectedRows.length === 0) { + setSelectedFile(params.data?.filename ?? ""); + } }} noRowsOverlayComponent={() => (
@@ -276,6 +371,24 @@ function SearchPage() { /> )}
+ + {/* Bulk Delete Confirmation Dialog */} + 1 ? "s" : "" + }? This will remove all chunks and data associated with these documents. This action cannot be undone. + +Documents to be deleted: +${selectedRows.map(row => `• ${row.filename}`).join("\n")}`} + confirmText="Delete All" + onConfirm={handleBulkDelete} + isLoading={deleteDocumentMutation.isPending} + />
); } diff --git a/frontend/src/components/AgGrid/agGridStyles.css b/frontend/src/components/AgGrid/agGridStyles.css index b595e18c..d1d7e07d 100644 --- a/frontend/src/components/AgGrid/agGridStyles.css +++ b/frontend/src/components/AgGrid/agGridStyles.css @@ -11,6 +11,12 @@ body { --ag-wrapper-border: none; --ag-font-family: var(--font-sans); + /* Checkbox styling */ + --ag-checkbox-background-color: hsl(var(--background)); + --ag-checkbox-border-color: hsl(var(--border)); + --ag-checkbox-checked-color: hsl(var(--primary)); + --ag-checkbox-unchecked-color: transparent; + .ag-header { border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0.5rem; @@ -18,4 +24,20 @@ body { .ag-row { cursor: pointer; } + + /* Make sure checkboxes are visible */ + .ag-selection-checkbox, + .ag-header-select-all { + opacity: 1 !important; + } + + .ag-checkbox-input-wrapper { + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + } + + .ag-checkbox-input-wrapper.ag-checked { + background-color: hsl(var(--primary)); + border-color: hsl(var(--primary)); + } } diff --git a/frontend/src/components/AgGrid/registerAgGridModules.ts b/frontend/src/components/AgGrid/registerAgGridModules.ts index da2c5280..6f3f7bc2 100644 --- a/frontend/src/components/AgGrid/registerAgGridModules.ts +++ b/frontend/src/components/AgGrid/registerAgGridModules.ts @@ -11,6 +11,7 @@ import { DateFilterModule, EventApiModule, GridStateModule, + RowSelectionModule, } from 'ag-grid-community'; // Importing necessary modules from ag-grid-community @@ -27,6 +28,7 @@ import { DateFilterModule, EventApiModule, GridStateModule, + RowSelectionModule, // The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development. ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), ]); diff --git a/src/config/settings.py b/src/config/settings.py index 715146fb..035ff388 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -89,8 +89,8 @@ INDEX_BODY = { "type": "knn_vector", "dimension": VECTOR_DIM, "method": { - "name": "disk_ann", - "engine": "jvector", + "name": "hnsw", + "engine": "lucene", "space_type": "l2", "parameters": {"ef_construction": 100, "m": 16}, }, @@ -255,8 +255,8 @@ class AppClients: self.opensearch = AsyncOpenSearch( hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}], connection_class=AIOHttpConnection, - scheme="https", - use_ssl=True, + scheme="http", + use_ssl=False, verify_certs=False, ssl_assert_fingerprint=None, http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), @@ -381,17 +381,18 @@ class AppClients: ) def create_user_opensearch_client(self, jwt_token: str): - """Create OpenSearch client with user's JWT token for OIDC auth""" - headers = {"Authorization": f"Bearer {jwt_token}"} + """Create OpenSearch client with basic auth (JWT not used in current setup)""" + # Note: jwt_token parameter kept for compatibility but not used + # Using basic auth instead of JWT Bearer tokens return AsyncOpenSearch( hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}], connection_class=AIOHttpConnection, - scheme="https", - use_ssl=True, + scheme="http", + use_ssl=False, verify_certs=False, ssl_assert_fingerprint=None, - headers=headers, + http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), # Use basic auth http_compress=True, )